feat: csa-dashboard for deploy argocd, no fix
This commit is contained in:
commit
bd44be14ff
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals"],
|
||||||
|
"parserOptions": {
|
||||||
|
"project": "./tsconfig.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.env.local
|
||||||
|
.env
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Build Stage
|
||||||
|
FROM node:20 AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production Image
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app ./
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "start"]
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
environment {
|
||||||
|
REGISTRY = "docker.io/syifamaulidya"
|
||||||
|
IMAGE_NAME = "admin-csa"
|
||||||
|
GITOPS_REPO = "https://git.winteraccess.id/syifa/admin-csa-gitops.git"
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
|
||||||
|
stage('Checkout Source Code') {
|
||||||
|
steps {
|
||||||
|
checkout scm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================
|
||||||
|
BUILD & PUSH NEXT.JS IMAGE
|
||||||
|
====================================== */
|
||||||
|
stage('Build & Push Image') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
withCredentials([usernamePassword(
|
||||||
|
credentialsId: 'gitops-dockerhub',
|
||||||
|
usernameVariable: 'DOCKER_USER',
|
||||||
|
passwordVariable: 'DOCKER_PASS'
|
||||||
|
)]) {
|
||||||
|
|
||||||
|
sh """
|
||||||
|
docker login -u $DOCKER_USER -p $DOCKER_PASS
|
||||||
|
|
||||||
|
echo "Building admin-csa image..."
|
||||||
|
docker build -t $REGISTRY/$IMAGE_NAME:$BUILD_NUMBER .
|
||||||
|
|
||||||
|
docker push $REGISTRY/$IMAGE_NAME:$BUILD_NUMBER
|
||||||
|
|
||||||
|
docker tag $REGISTRY/$IMAGE_NAME:$BUILD_NUMBER $REGISTRY/$IMAGE_NAME:latest
|
||||||
|
docker push $REGISTRY/$IMAGE_NAME:latest
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
env.IMAGE_TAG = "${REGISTRY}/${IMAGE_NAME}:${BUILD_NUMBER}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================
|
||||||
|
UPDATE GITOPS
|
||||||
|
====================================== */
|
||||||
|
stage('Update GitOps (dev, staging, prod)') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
|
||||||
|
def branches = [
|
||||||
|
[name: "dev", overlay: "overlays/dev"],
|
||||||
|
[name: "staging", overlay: "overlays/staging"],
|
||||||
|
[name: "production", overlay: "overlays/production"]
|
||||||
|
]
|
||||||
|
|
||||||
|
withCredentials([usernamePassword(
|
||||||
|
credentialsId: 'gitea-token-gitops',
|
||||||
|
usernameVariable: 'GITEA_USER',
|
||||||
|
passwordVariable: 'GITEA_PASS'
|
||||||
|
)]) {
|
||||||
|
|
||||||
|
branches.each { envSet ->
|
||||||
|
|
||||||
|
echo "Updating GitOps branch: ${envSet.name}"
|
||||||
|
|
||||||
|
sh(
|
||||||
|
script: """
|
||||||
|
if ! command -v ./yq &> /dev/null; then
|
||||||
|
wget -qO ./yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
|
||||||
|
chmod +x ./yq
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf gitops
|
||||||
|
git -c http.sslVerify=false clone -b ${envSet.name} \
|
||||||
|
https://$GITEA_USER:$GITEA_PASS@git.winteraccess.id/syifa/admin-csa-gitops.git gitops
|
||||||
|
|
||||||
|
cd gitops
|
||||||
|
|
||||||
|
echo "Updating image tag..."
|
||||||
|
|
||||||
|
../yq e -i \
|
||||||
|
".spec.template.spec.containers[] |= select(.name == \\"admin-csa\\").image = env(IMAGE_TAG)" \
|
||||||
|
${envSet.overlay}/patch-deployment.yaml
|
||||||
|
|
||||||
|
git config user.name "jenkins"
|
||||||
|
git config user.email "jenkins@gitops.local"
|
||||||
|
|
||||||
|
git add .
|
||||||
|
git commit -m "Update admin-csa image to build $BUILD_NUMBER" || echo "No changes"
|
||||||
|
git push origin ${envSet.name}
|
||||||
|
|
||||||
|
cd ..
|
||||||
|
rm -rf gitops
|
||||||
|
""",
|
||||||
|
mask: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
echo "GitOps updated successfully!"
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
echo "Pipeline failed."
|
||||||
|
}
|
||||||
|
always {
|
||||||
|
cleanWs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
# Admin CSA - Enterprise Dashboard
|
||||||
|
|
||||||
|
Modern, scalable admin dashboard built with Next.js 15, TypeScript, and shadcn/ui. Auto-generated from Postman collection with complete CRUD operations for all endpoints.
|
||||||
|
- **Framework:** Next.js 15.1.3
|
||||||
|
- **Language:** TypeScript 5
|
||||||
|
- **Styling:** Tailwind CSS 3.4
|
||||||
|
- **UI Components:** shadcn/ui (Radix UI)
|
||||||
|
- **State Management:** React Query 5
|
||||||
|
- **Form Handling:** react-hook-form 7
|
||||||
|
- **Validation:** Zod 3
|
||||||
|
- **HTTP Client:** Axios 1.7
|
||||||
|
- **Icons:** Lucide React
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
admin-csa/
|
||||||
|
├── app/ # Next.js App Router
|
||||||
|
│ ├── (dashboard)/ # Protected dashboard routes
|
||||||
|
│ │ ├── api-management/
|
||||||
|
│ │ ├── cms/
|
||||||
|
│ │ │ ├── content/
|
||||||
|
│ │ │ └── buckets/
|
||||||
|
│ │ ├── users/
|
||||||
|
│ │ └── openai/
|
||||||
|
│ ├── login/
|
||||||
|
│ ├── register/
|
||||||
|
│ ├── layout.tsx
|
||||||
|
│ └── globals.css
|
||||||
|
├── components/ # Reusable components
|
||||||
|
│ ├── ui/ # shadcn UI components
|
||||||
|
│ ├── layout/ # Layout components
|
||||||
|
│ └── data-table/ # DataTable components
|
||||||
|
├── modules/ # Feature modules
|
||||||
|
│ ├── admin/
|
||||||
|
│ ├── api-management/
|
||||||
|
│ ├── cms/
|
||||||
|
│ │ ├── content/
|
||||||
|
│ │ └── bucket/
|
||||||
|
│ ├── users/
|
||||||
|
│ └── openai/
|
||||||
|
├── providers/ # Context providers
|
||||||
|
├── lib/ # Utilities
|
||||||
|
├── config/ # Configuration
|
||||||
|
└── hooks/ # Custom hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm or yarn
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd admin-csa
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Configure environment variables:
|
||||||
|
```bash
|
||||||
|
# .env file is already configured with:
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=https://api-management.cifo.co.id
|
||||||
|
NEXT_PUBLIC_API_KEY=cifosuperapp
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run the development server:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Open [http://localhost:3000](http://localhost:3000)
|
||||||
|
|
||||||
|
### Default Credentials
|
||||||
|
|
||||||
|
```
|
||||||
|
Email: admin@csa.id
|
||||||
|
Password: aadmin1234
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Adding a New Module
|
||||||
|
|
||||||
|
1. Create module structure:
|
||||||
|
```
|
||||||
|
modules/
|
||||||
|
└── your-module/
|
||||||
|
├── schemas.ts # Zod validation schemas
|
||||||
|
├── services.ts # API service functions
|
||||||
|
└── hooks.ts # React Query hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create pages:
|
||||||
|
```
|
||||||
|
app/(dashboard)/
|
||||||
|
└── your-module/
|
||||||
|
├── page.tsx # List view
|
||||||
|
├── create/page.tsx # Create form
|
||||||
|
└── edit/[id]/page.tsx # Edit form
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add route to sidebar in `components/layout/sidebar.tsx`
|
||||||
|
|
||||||
|
### API Integration
|
||||||
|
|
||||||
|
All API calls are centralized in module services:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// modules/your-module/services.ts
|
||||||
|
import { apiClient } from "@/lib/api-client";
|
||||||
|
|
||||||
|
export const yourService = {
|
||||||
|
getAll: async () => {
|
||||||
|
const response = await apiClient.get("/endpoint");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Validation
|
||||||
|
|
||||||
|
Use Zod schemas for type-safe validation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// modules/your-module/schemas.ts
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const yourSchema = z.object({
|
||||||
|
field: z.string().min(1, "Required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type YourInput = z.infer<typeof yourSchema>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Highlights
|
||||||
|
|
||||||
|
### Modular Structure
|
||||||
|
Each feature is self-contained with its own schemas, services, and hooks.
|
||||||
|
|
||||||
|
### Type Safety
|
||||||
|
Full TypeScript coverage with strict mode enabled.
|
||||||
|
|
||||||
|
### Auto-generated from Postman
|
||||||
|
All endpoints from the Postman collection are automatically mapped to modules.
|
||||||
|
|
||||||
|
### Scalable
|
||||||
|
Easy to add new modules following the established patterns.
|
||||||
|
|
||||||
|
### Enterprise-Ready
|
||||||
|
- Clean code architecture
|
||||||
|
- SOLID principles
|
||||||
|
- DRY approach
|
||||||
|
- Maintainable codebase
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=https://api-management.cifo.co.id
|
||||||
|
NEXT_PUBLIC_API_KEY=cifosuperapp
|
||||||
|
NEXT_PUBLIC_API_MANAGEMENT=api-management
|
||||||
|
NEXT_PUBLIC_CMS_MANAGEMENT=cms-management
|
||||||
|
NEXT_PUBLIC_BUCKET_MANAGEMENT=bucket-management
|
||||||
|
NEXT_PUBLIC_USER_MANAGEMENT=user-management
|
||||||
|
NEXT_PUBLIC_ADMIN_MANAGEMENT=admin-management
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
- `npm run dev` - Start development server with Turbopack
|
||||||
|
- `npm run build` - Build for production
|
||||||
|
- `npm start` - Start production server
|
||||||
|
- `npm run lint` - Run ESLint
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and questions, please open an issue in the repository.
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { DataTable } from "@/components/data-table/data-table";
|
||||||
|
import { useApiTokens, useCreateApiKey, useDeleteApiKey, useTestSecure } from "@/modules/api-management/hooks";
|
||||||
|
import { ApiToken } from "@/modules/api-management/schemas";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Key, Trash, TestTube, MoreHorizontal, Plus, Eye, Copy } from "lucide-react";
|
||||||
|
import { formatDateTime } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
export default function ApiManagementPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { data, isLoading } = useApiTokens();
|
||||||
|
const createMutation = useCreateApiKey();
|
||||||
|
const deleteMutation = useDeleteApiKey();
|
||||||
|
const { refetch: testSecure } = useTestSecure();
|
||||||
|
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||||
|
const [selectedToken, setSelectedToken] = useState<ApiToken | null>(null);
|
||||||
|
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||||
|
const [deleteToken, setDeleteToken] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
try {
|
||||||
|
const response = await createMutation.mutateAsync();
|
||||||
|
setApiKey(response.apiKey || response.token);
|
||||||
|
toast({ title: "API Key created", description: "New API key generated successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
toast({ title: "Create failed", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteToken) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(deleteToken);
|
||||||
|
toast({ title: "API Key deleted", description: "API token has been deleted successfully" });
|
||||||
|
setDeleteToken(null);
|
||||||
|
} catch (error) {
|
||||||
|
toast({ title: "Delete failed", variant: "destructive" });
|
||||||
|
setDeleteToken(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast({ title: "Copied to clipboard", description: "API key has been copied successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
toast({ title: "Copy failed", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenDetail = (token: ApiToken) => {
|
||||||
|
setSelectedToken(token);
|
||||||
|
setIsDetailOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<ApiToken>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "TokenCredential_AC",
|
||||||
|
header: "Token",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="font-mono text-sm max-w-[300px] truncate">
|
||||||
|
{row.original.TokenCredential_AC}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "CreatedAt_AC",
|
||||||
|
header: "Created",
|
||||||
|
cell: ({ row }) => formatDateTime(row.original.CreatedAt_AC),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "UpdatedAt_AC",
|
||||||
|
header: "Updated",
|
||||||
|
cell: ({ row }) => formatDateTime(row.original.UpdatedAt_AC),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handleOpenDetail(row.original)}>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
View Details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setDeleteToken(row.original.TokenCredential_AC)}
|
||||||
|
className="text-destructive"
|
||||||
|
>
|
||||||
|
<Trash className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = data?.data?.tokens || [];
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
try {
|
||||||
|
await testSecure();
|
||||||
|
toast({ title: "Test successful", description: "API connection is working" });
|
||||||
|
} catch (error) {
|
||||||
|
toast({ title: "Test failed", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">API Management</h1>
|
||||||
|
<p className="text-muted-foreground">Manage API keys and tokens</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCreate} disabled={createMutation.isPending}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{createMutation.isPending ? "Creating..." : "Create Token"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{apiKey && (
|
||||||
|
<Card className="bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-green-900 dark:text-green-100">New API Token Created</CardTitle>
|
||||||
|
<CardDescription className="text-green-700 dark:text-green-300">
|
||||||
|
Copy and save this token securely. You won't be able to see it again.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-lg bg-white dark:bg-slate-900 p-4 border">
|
||||||
|
<p className="text-sm font-mono break-all">{apiKey}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>API Tokens</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Total: {data?.data?.total || 0} tokens
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DataTable columns={columns} data={tokens} searchKey="TokenCredential_AC" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>API Testing</CardTitle>
|
||||||
|
<CardDescription>Test API connectivity</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button onClick={handleTest}>
|
||||||
|
<TestTube className="mr-2 h-4 w-4" />
|
||||||
|
Test Secure Endpoint
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={isDetailOpen} onOpenChange={setIsDetailOpen}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>API Token Details</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
View complete API token information
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{selectedToken && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">Token Credential</label>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<div className="flex-1 rounded-lg bg-muted p-3 font-mono text-sm break-all">
|
||||||
|
{selectedToken.TokenCredential_AC}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleCopy(selectedToken.TokenCredential_AC)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">Created At</label>
|
||||||
|
<p className="mt-1 text-sm">{formatDateTime(selectedToken.CreatedAt_AC)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">Updated At</label>
|
||||||
|
<p className="mt-1 text-sm">{formatDateTime(selectedToken.UpdatedAt_AC)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={!!deleteToken} onOpenChange={(open) => !open && setDeleteToken(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete API Key</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete this API key? This action cannot be undone and will immediately revoke access for this token.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,805 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { DataTable } from "@/components/data-table/data-table";
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useCampaignList, useCancelCampaign, useCampaignReport, useUpdateCampaign, useDeleteCampaign } from "@/modules/campaigns/hooks";
|
||||||
|
import { Campaign } from "@/modules/campaigns/schemas";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { MoreHorizontal, Plus, Send, Trash, Calendar, Filter, Eye, CheckCircle2, XCircle, Clock, AlertCircle, FileText, TrendingUp, Target, Users, RefreshCw } from "lucide-react";
|
||||||
|
import { formatDateTime } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function CampaignsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
|
||||||
|
const [isRefetching, setIsRefetching] = useState(false);
|
||||||
|
const { data, isLoading, refetch } = useCampaignList(statusFilter);
|
||||||
|
const cancelMutation = useCancelCampaign();
|
||||||
|
const updateMutation = useUpdateCampaign();
|
||||||
|
const deleteMutation = useDeleteCampaign();
|
||||||
|
const [cancelCampaign, setCancelCampaign] = useState<{ id: string; title: string } | null>(null);
|
||||||
|
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
|
||||||
|
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||||
|
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||||
|
const [editFormData, setEditFormData] = useState<{ title?: string; content?: string; date?: string; status?: "pending" | "completed" | "cancelled" | "failed" }>({});
|
||||||
|
const [reportCampaignId, setReportCampaignId] = useState<string | null>(null);
|
||||||
|
const [isReportOpen, setIsReportOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleRefetch = async () => {
|
||||||
|
setIsRefetching(true);
|
||||||
|
try {
|
||||||
|
await refetch();
|
||||||
|
} finally {
|
||||||
|
setIsRefetching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (!cancelCampaign) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cancelMutation.mutateAsync(cancelCampaign.id);
|
||||||
|
toast({
|
||||||
|
title: "Campaign cancelled",
|
||||||
|
description: "Campaign has been cancelled successfully",
|
||||||
|
});
|
||||||
|
setCancelCampaign(null);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Cancel failed",
|
||||||
|
description: "Failed to cancel campaign",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
setCancelCampaign(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = async () => {
|
||||||
|
if (!selectedCampaign || !editFormData.title) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateMutation.mutateAsync({
|
||||||
|
id: selectedCampaign.UUID_ACP,
|
||||||
|
data: editFormData,
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: "Campaign updated",
|
||||||
|
description: "Campaign has been updated successfully",
|
||||||
|
});
|
||||||
|
setIsEditOpen(false);
|
||||||
|
setEditFormData({});
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Update failed",
|
||||||
|
description: "Failed to update campaign",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (campaignId: string) => {
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(campaignId);
|
||||||
|
toast({
|
||||||
|
title: "Campaign deleted",
|
||||||
|
description: "Campaign has been cancelled successfully",
|
||||||
|
});
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Delete failed",
|
||||||
|
description: "Failed to delete campaign",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string | undefined) => {
|
||||||
|
if (!status) {
|
||||||
|
return <Badge variant="default">Unknown</Badge>;
|
||||||
|
}
|
||||||
|
const variants: Record<string, "default" | "secondary" | "destructive"> = {
|
||||||
|
pending: "secondary",
|
||||||
|
completed: "default",
|
||||||
|
cancelled: "destructive",
|
||||||
|
failed: "destructive",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Badge variant={variants[status] || "default"}>
|
||||||
|
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<Campaign>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "Title_ACP",
|
||||||
|
header: "Title",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="font-medium">{row.original.Title_ACP || "-"}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "Content_ACP",
|
||||||
|
header: "Content",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="max-w-[200px] truncate text-muted-foreground">
|
||||||
|
{row.original.Content_ACP || "-"}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "Date_ACP",
|
||||||
|
header: "Scheduled Date",
|
||||||
|
cell: ({ row }) => row.original.Date_ACP ? formatDateTime(row.original.Date_ACP) : "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "Status_ACP",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => getStatusBadge(row.original.Status_ACP),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "TargetUsers_ACP",
|
||||||
|
header: "Target",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="text-center font-mono">{row.original.TargetUsers_ACP}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "SentCount_ACP",
|
||||||
|
header: "Sent",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="text-center font-mono">{row.original.SentCount_ACP}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "DeliveryRate_ACP",
|
||||||
|
header: "Delivery Rate",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const rate = row.original.DeliveryRate_ACP;
|
||||||
|
return (
|
||||||
|
<div className="text-center">
|
||||||
|
<span className={`font-semibold ${rate >= 90 ? 'text-green-600' : rate >= 70 ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||||
|
{rate}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "CreatedAt_ACP",
|
||||||
|
header: "Created",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{row.original.CreatedAt_ACP ? formatDateTime(row.original.CreatedAt_ACP) : "-"}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCampaign(row.original);
|
||||||
|
setIsDetailOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
View Details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setReportCampaignId(row.original.UUID_ACP);
|
||||||
|
setIsReportOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
View Report
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{row.original.Status_ACP === "pending" && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCampaign(row.original);
|
||||||
|
setEditFormData({
|
||||||
|
title: row.original.Title_ACP,
|
||||||
|
content: row.original.Content_ACP,
|
||||||
|
date: row.original.Date_ACP,
|
||||||
|
});
|
||||||
|
setIsEditOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setCancelCampaign({ id: row.original.UUID_ACP, title: row.original.Title_ACP })}
|
||||||
|
className="text-destructive"
|
||||||
|
>
|
||||||
|
<Trash className="mr-2 h-4 w-4" />
|
||||||
|
Cancel
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const campaigns = data?.data || [];
|
||||||
|
|
||||||
|
const filterLabel = statusFilter
|
||||||
|
? statusFilter.charAt(0).toUpperCase() + statusFilter.slice(1)
|
||||||
|
: "All Status";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Campaign Management</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage and schedule notification campaigns
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleRefetch()}
|
||||||
|
title="Refresh campaigns"
|
||||||
|
disabled={isRefetching || isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isRefetching || isLoading ? 'animate-spin-smooth' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Filter className="mr-2 h-4 w-4" />
|
||||||
|
{filterLabel}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Filter by Status</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => setStatusFilter(undefined)}>
|
||||||
|
All Status
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setStatusFilter("pending")}>
|
||||||
|
Pending
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setStatusFilter("completed")}>
|
||||||
|
Completed
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setStatusFilter("cancelled")}>
|
||||||
|
Cancelled
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setStatusFilter("failed")}>
|
||||||
|
Failed
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push("/campaigns/send")}
|
||||||
|
>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
Send Single
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => router.push("/campaigns/setup")}>
|
||||||
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
|
Setup Campaign
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{campaigns.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No campaigns yet"
|
||||||
|
description="Create your first campaign to get started"
|
||||||
|
action={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push("/campaigns/send")}
|
||||||
|
>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
Send Single
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => router.push("/campaigns/setup")}>
|
||||||
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
|
Setup Campaign
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable columns={columns} data={campaigns} searchKey="Title_ACP" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AlertDialog open={!!cancelCampaign} onOpenChange={(open) => !open && setCancelCampaign(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Cancel Campaign</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to cancel the campaign "{cancelCampaign?.title}"? This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Keep Campaign</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Cancel Campaign
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Campaign</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update campaign details
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{selectedCampaign && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editFormData.title || ""}
|
||||||
|
onChange={(e) => setEditFormData({ ...editFormData, title: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-md border-input bg-background"
|
||||||
|
placeholder="Campaign title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Content</label>
|
||||||
|
<textarea
|
||||||
|
value={editFormData.content || ""}
|
||||||
|
onChange={(e) => setEditFormData({ ...editFormData, content: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-md border-input bg-background min-h-24"
|
||||||
|
placeholder="Campaign content"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Scheduled Date</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={editFormData.date ? new Date(editFormData.date).toISOString().slice(0, 16) : ""}
|
||||||
|
onChange={(e) => setEditFormData({ ...editFormData, date: new Date(e.target.value).toISOString() })}
|
||||||
|
className="w-full px-3 py-2 border rounded-md border-input bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end mt-6">
|
||||||
|
<Button variant="outline" onClick={() => setIsEditOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleEdit} disabled={updateMutation.isPending}>
|
||||||
|
{updateMutation.isPending ? "Updating..." : "Update Campaign"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={isDetailOpen} onOpenChange={setIsDetailOpen}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
Campaign Details
|
||||||
|
{selectedCampaign && getStatusBadge(selectedCampaign.Status_ACP)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Complete information about this campaign
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{selectedCampaign && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground">Basic Information</h3>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">Campaign ID:</span>
|
||||||
|
<span className="col-span-2 text-sm font-mono">{selectedCampaign.UUID_ACP}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">Title:</span>
|
||||||
|
<span className="col-span-2 text-sm font-medium">{selectedCampaign.Title_ACP}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">Content:</span>
|
||||||
|
<span className="col-span-2 text-sm">{selectedCampaign.Content_ACP}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">Scheduled:</span>
|
||||||
|
<span className="col-span-2 text-sm">{formatDateTime(selectedCampaign.Date_ACP)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delivery Stats */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground">Delivery Statistics</h3>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="glass border rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">Target Users</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-semibold">{selectedCampaign.TargetUsers_ACP}</p>
|
||||||
|
</div>
|
||||||
|
<div className="glass border rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Send className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">Sent Count</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-semibold">{selectedCampaign.SentCount_ACP}</p>
|
||||||
|
</div>
|
||||||
|
<div className="glass border rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="text-xs text-muted-foreground">Success</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-semibold text-green-600">{selectedCampaign.SuccessCount_ACP}</p>
|
||||||
|
</div>
|
||||||
|
<div className="glass border rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<XCircle className="h-4 w-4 text-red-600" />
|
||||||
|
<span className="text-xs text-muted-foreground">Failures</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-semibold text-red-600">{selectedCampaign.FailureCount_ACP}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="glass border rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Delivery Rate</span>
|
||||||
|
<span className={`text-2xl font-bold ${selectedCampaign.DeliveryRate_ACP >= 90 ? 'text-green-600' :
|
||||||
|
selectedCampaign.DeliveryRate_ACP >= 70 ? 'text-yellow-600' :
|
||||||
|
'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{selectedCampaign.DeliveryRate_ACP}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timestamps */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground">Timeline</h3>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">Created:</span>
|
||||||
|
<span className="font-mono">{formatDateTime(selectedCampaign.CreatedAt_ACP)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">Updated:</span>
|
||||||
|
<span className="font-mono">{formatDateTime(selectedCampaign.UpdatedAt_ACP)}</span>
|
||||||
|
</div>
|
||||||
|
{selectedCampaign.SentAt_ACP && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Send className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">Sent At:</span>
|
||||||
|
<span className="font-mono">{formatDateTime(selectedCampaign.SentAt_ACP)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedCampaign.CompletedAt_ACP && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="text-muted-foreground">Completed:</span>
|
||||||
|
<span className="font-mono">{formatDateTime(selectedCampaign.CompletedAt_ACP)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{selectedCampaign.ErrorMessage_ACP && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4 text-red-600" />
|
||||||
|
Error Message
|
||||||
|
</h3>
|
||||||
|
<div className="glass border border-red-600/20 rounded-lg p-4 bg-red-50/50 dark:bg-red-950/20">
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400 font-mono">
|
||||||
|
{selectedCampaign.ErrorMessage_ACP}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<CampaignReportDialog
|
||||||
|
campaignId={reportCampaignId}
|
||||||
|
isOpen={isReportOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsReportOpen(false);
|
||||||
|
setReportCampaignId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CampaignReportDialog({ campaignId, isOpen, onClose }: { campaignId: string | null; isOpen: boolean; onClose: () => void }) {
|
||||||
|
const { data: reportData, isLoading } = useCampaignReport(campaignId || "");
|
||||||
|
const report = reportData?.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
Campaign Report
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Detailed performance and delivery report
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||||
|
<p className="text-sm text-muted-foreground">Loading report...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : report ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Campaign Info */}
|
||||||
|
<div className="glass border rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">{report.campaign.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{report.campaign.content}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={report.campaign.status === "completed" ? "default" : report.campaign.status === "failed" ? "destructive" : "secondary"}>
|
||||||
|
{report.campaign.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Campaign ID:</span>
|
||||||
|
<p className="font-mono mt-1">{report.campaign.id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Scheduled Date:</span>
|
||||||
|
<p className="mt-1">{formatDateTime(report.campaign.scheduledDate)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground">Performance Metrics</h3>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="glass border rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">Lead Time</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-semibold">{report.metrics.leadTime}</p>
|
||||||
|
</div>
|
||||||
|
<div className="glass border rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">Execution Time</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-semibold">{report.metrics.executionTime}</p>
|
||||||
|
</div>
|
||||||
|
<div className="glass border rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Target className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">Time Until Execution</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-semibold">{report.metrics.timeUntilExecution}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="glass border rounded-lg p-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Scheduled</span>
|
||||||
|
<Badge variant={report.metrics.isScheduled ? "default" : "secondary"}>
|
||||||
|
{report.metrics.isScheduled ? "Yes" : "No"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="glass border rounded-lg p-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Overdue</span>
|
||||||
|
<Badge variant={report.metrics.isOverdue ? "destructive" : "default"}>
|
||||||
|
{report.metrics.isOverdue ? "Yes" : "No"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="glass border rounded-lg p-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Avg Delivery</span>
|
||||||
|
<span className="text-sm font-semibold">{report.metrics.avgDeliveryTime}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delivery Stats */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground">Delivery Statistics</h3>
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
<div className="glass border rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">Target Users</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-semibold">{report.delivery.targetUsers}</p>
|
||||||
|
</div>
|
||||||
|
<div className="glass border rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Send className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">Sent</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-semibold">{report.delivery.sentCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="glass border rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="text-xs text-muted-foreground">Success</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-semibold text-green-600">{report.delivery.successCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="glass border rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<XCircle className="h-4 w-4 text-red-600" />
|
||||||
|
<span className="text-xs text-muted-foreground">Failed</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-semibold text-red-600">{report.delivery.failureCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="glass border rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Overall Delivery Rate</span>
|
||||||
|
<span className="text-2xl font-bold">{report.delivery.deliveryRate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Breakdown */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground">Status Breakdown</h3>
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
<div className="border rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-semibold">{report.delivery.statusBreakdown.pending}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Pending</p>
|
||||||
|
</div>
|
||||||
|
<div className="border rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-semibold">{report.delivery.statusBreakdown.sent}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Sent</p>
|
||||||
|
</div>
|
||||||
|
<div className="border rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-semibold text-green-600">{report.delivery.statusBreakdown.delivered}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Delivered</p>
|
||||||
|
</div>
|
||||||
|
<div className="border rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-semibold text-red-600">{report.delivery.statusBreakdown.failed}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Failed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground">Timeline</h3>
|
||||||
|
<div className="space-y-2 border rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground w-24">Created:</span>
|
||||||
|
<span className="font-mono">{formatDateTime(report.timeline.created)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground w-24">Scheduled:</span>
|
||||||
|
<span className="font-mono">{formatDateTime(report.timeline.scheduled)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground w-24">Executed:</span>
|
||||||
|
<span className="font-mono">{formatDateTime(report.timeline.executed)}</span>
|
||||||
|
</div>
|
||||||
|
{report.timeline.sent && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Send className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground w-24">Sent:</span>
|
||||||
|
<span className="font-mono">{formatDateTime(report.timeline.sent)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{report.timeline.completed && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="text-muted-foreground w-24">Completed:</span>
|
||||||
|
<span className="font-mono">{formatDateTime(report.timeline.completed)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delivery Records */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground">Delivery Records</h3>
|
||||||
|
<div className="glass border rounded-lg p-4 text-center">
|
||||||
|
<p className="text-3xl font-bold">{report.deliveryRecords.total}</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Total Delivery Records</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{report.campaign.errorMessage && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4 text-red-600" />
|
||||||
|
Error Message
|
||||||
|
</h3>
|
||||||
|
<div className="glass border border-red-600/20 rounded-lg p-4 bg-red-50/50 dark:bg-red-950/20">
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400 font-mono">
|
||||||
|
{report.campaign.errorMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No report data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useSendCampaign } from "@/modules/campaigns/hooks";
|
||||||
|
import { sendCampaignSchema, type SendCampaignInput } from "@/modules/campaigns/schemas";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { ArrowLeft, Send } from "lucide-react";
|
||||||
|
|
||||||
|
export default function SendCampaignPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const sendMutation = useSendCampaign();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<SendCampaignInput>({
|
||||||
|
resolver: zodResolver(sendCampaignSchema),
|
||||||
|
defaultValues: {
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: SendCampaignInput) => {
|
||||||
|
try {
|
||||||
|
await sendMutation.mutateAsync(data);
|
||||||
|
toast({
|
||||||
|
title: "Campaign sent",
|
||||||
|
description: "Campaign has been sent successfully to the user",
|
||||||
|
});
|
||||||
|
reset();
|
||||||
|
router.push("/campaigns");
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Send failed",
|
||||||
|
description: "Failed to send campaign",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => router.push("/campaigns")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Send Single Campaign</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Send an immediate campaign to a specific user
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Send className="h-5 w-5" />
|
||||||
|
Send Campaign
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Send a notification campaign immediately to a specific user
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="userID">User ID</Label>
|
||||||
|
<Input
|
||||||
|
id="userID"
|
||||||
|
{...register("userID")}
|
||||||
|
placeholder="CH9QX5WB"
|
||||||
|
/>
|
||||||
|
{errors.userID && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{errors.userID.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
{...register("title")}
|
||||||
|
placeholder="Notification title"
|
||||||
|
/>
|
||||||
|
{errors.title && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{errors.title.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="body">Body</Label>
|
||||||
|
<Input
|
||||||
|
id="body"
|
||||||
|
{...register("body")}
|
||||||
|
placeholder="Notification message"
|
||||||
|
/>
|
||||||
|
{errors.body && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{errors.body.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push("/campaigns")}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={sendMutation.isPending}>
|
||||||
|
{sendMutation.isPending ? "Sending..." : "Send Campaign"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useSetupCampaign } from "@/modules/campaigns/hooks";
|
||||||
|
import { setupCampaignSchema, type SetupCampaignInput } from "@/modules/campaigns/schemas";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { ArrowLeft, Calendar } from "lucide-react";
|
||||||
|
|
||||||
|
export default function SetupCampaignPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const setupMutation = useSetupCampaign();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<SetupCampaignInput>({
|
||||||
|
resolver: zodResolver(setupCampaignSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: SetupCampaignInput) => {
|
||||||
|
try {
|
||||||
|
const isoDate = data.date.includes('T') ? data.date + ':00.000Z' : data.date;
|
||||||
|
await setupMutation.mutateAsync({
|
||||||
|
...data,
|
||||||
|
date: isoDate,
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: "Campaign scheduled",
|
||||||
|
description: "Campaign has been scheduled successfully",
|
||||||
|
});
|
||||||
|
reset();
|
||||||
|
router.push("/campaigns");
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Setup failed",
|
||||||
|
description: "Failed to schedule campaign",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => router.push("/campaigns")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Setup Campaign</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Schedule a campaign to be sent at a specific time
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-5 w-5" />
|
||||||
|
Schedule Campaign
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create a scheduled campaign that will be sent to all users at the
|
||||||
|
specified date and time
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
{...register("title")}
|
||||||
|
placeholder="Campaign title"
|
||||||
|
/>
|
||||||
|
{errors.title && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{errors.title.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="content">Content</Label>
|
||||||
|
<Input
|
||||||
|
id="content"
|
||||||
|
{...register("content")}
|
||||||
|
placeholder="Campaign message content"
|
||||||
|
/>
|
||||||
|
{errors.content && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{errors.content.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="date">Scheduled Date & Time</Label>
|
||||||
|
<Input
|
||||||
|
id="date"
|
||||||
|
type="datetime-local"
|
||||||
|
{...register("date")}
|
||||||
|
/>
|
||||||
|
{errors.date && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{errors.date.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Format: ISO 8601 (e.g., 2025-11-26T08:15:00Z)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push("/campaigns")}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={setupMutation.isPending}>
|
||||||
|
{setupMutation.isPending
|
||||||
|
? "Scheduling..."
|
||||||
|
: "Schedule Campaign"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { DataTable } from "@/components/data-table/data-table";
|
||||||
|
import { useBucketList, useCreateBucket, useDeleteBucket } from "@/modules/cms/bucket/hooks";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Plus, Trash } from "lucide-react";
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { Bucket } from "@/modules/cms/bucket/schemas";
|
||||||
|
|
||||||
|
export default function BucketsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { data, isLoading } = useBucketList();
|
||||||
|
const createMutation = useCreateBucket();
|
||||||
|
const deleteMutation = useDeleteBucket();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [bucketName, setBucketName] = useState("");
|
||||||
|
const [deleteBucket, setDeleteBucket] = useState<{ name: string; id: string } | null>(null);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!bucketName) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createMutation.mutateAsync({ bucketName });
|
||||||
|
toast({ title: "Bucket created", description: "Bucket created successfully" });
|
||||||
|
setOpen(false);
|
||||||
|
setBucketName("");
|
||||||
|
} catch (error) {
|
||||||
|
toast({ title: "Create failed", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteBucket) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(deleteBucket.name);
|
||||||
|
toast({ title: "Bucket deleted", description: "Bucket has been deleted successfully" });
|
||||||
|
setDeleteBucket(null);
|
||||||
|
} catch (error) {
|
||||||
|
toast({ title: "Delete failed", variant: "destructive" });
|
||||||
|
setDeleteBucket(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<Bucket>[] = [
|
||||||
|
{ accessorKey: "name", header: "Bucket Name" },
|
||||||
|
{ accessorKey: "policy", header: "Policy" },
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setDeleteBucket({ name: row.original.name, id: row.original.name })}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const buckets = data?.data || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">CMS Buckets</h1>
|
||||||
|
<p className="text-muted-foreground">Manage storage buckets</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Bucket
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Bucket</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bucketName">Bucket Name</Label>
|
||||||
|
<Input
|
||||||
|
id="bucketName"
|
||||||
|
value={bucketName}
|
||||||
|
onChange={(e) => setBucketName(e.target.value)}
|
||||||
|
placeholder="my-bucket"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCreate} disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? "Creating..." : "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div>Loading...</div>
|
||||||
|
) : (
|
||||||
|
<DataTable columns={columns} data={buckets} searchKey="name" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AlertDialog open={!!deleteBucket} onOpenChange={(open) => !open && setDeleteBucket(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Bucket</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete bucket "{deleteBucket?.name}"? This action cannot be undone and will remove all data in this bucket.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { useCreateContent } from "@/modules/cms/content/hooks";
|
||||||
|
import { contentSchema, type ContentInput, CmsType } from "@/modules/cms/content/schemas";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { CMS_TYPES, CMS_TYPE_LABELS, CORP_TYPES, CORP_TYPE_LABELS } from "@/config/constants";
|
||||||
|
|
||||||
|
export default function CreateContentPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const type = params.type as CmsType;
|
||||||
|
const createMutation = useCreateContent();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<ContentInput>({
|
||||||
|
resolver: zodResolver(contentSchema),
|
||||||
|
defaultValues: {
|
||||||
|
type: type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate type
|
||||||
|
const validTypes = Object.values(CMS_TYPES);
|
||||||
|
if (!validTypes.includes(type)) {
|
||||||
|
router.push("/cms/content");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (data: ContentInput) => {
|
||||||
|
try {
|
||||||
|
const fileInput = document.getElementById("image") as HTMLInputElement;
|
||||||
|
if (fileInput?.files?.[0]) {
|
||||||
|
data.image = fileInput.files[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
await createMutation.mutateAsync(data);
|
||||||
|
toast({
|
||||||
|
title: "Content created",
|
||||||
|
description: "Content has been created successfully",
|
||||||
|
});
|
||||||
|
router.push(`/cms/content/${type}`);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Create failed",
|
||||||
|
description: "Failed to create content",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Create {CMS_TYPE_LABELS[type]}</h1>
|
||||||
|
<p className="text-muted-foreground">Add new {CMS_TYPE_LABELS[type].toLowerCase()} content</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Content Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<input type="hidden" {...register("type")} value={type} />
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Title *</Label>
|
||||||
|
<Input id="title" {...register("title")} />
|
||||||
|
{errors.title && (
|
||||||
|
<p className="text-sm text-destructive">{errors.title.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="corp">Corp Type *</Label>
|
||||||
|
<select
|
||||||
|
id="corp"
|
||||||
|
{...register("corp")}
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">Select corp type</option>
|
||||||
|
{Object.values(CORP_TYPES).map((corpType) => (
|
||||||
|
<option key={corpType} value={corpType}>
|
||||||
|
{CORP_TYPE_LABELS[corpType]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.corp && (
|
||||||
|
<p className="text-sm text-destructive">{errors.corp.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Input id="description" {...register("description")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="targetUrl">Target URL</Label>
|
||||||
|
<Input id="targetUrl" {...register("targetUrl")} placeholder="https://..." />
|
||||||
|
{errors.targetUrl && (
|
||||||
|
<p className="text-sm text-destructive">{errors.targetUrl.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="image">Image</Label>
|
||||||
|
<Input id="image" type="file" accept="image/*" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? "Creating..." : "Create Content"}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { useUpdateContent } from "@/modules/cms/content/hooks";
|
||||||
|
import { contentUpdateSchema, type ContentUpdateInput, CmsType } from "@/modules/cms/content/schemas";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { CMS_TYPES, CMS_TYPE_LABELS, CORP_TYPES, CORP_TYPE_LABELS } from "@/config/constants";
|
||||||
|
|
||||||
|
export default function EditContentPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const type = params.type as CmsType;
|
||||||
|
const id = params.id as string;
|
||||||
|
const updateMutation = useUpdateContent();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<ContentUpdateInput>({
|
||||||
|
resolver: zodResolver(contentUpdateSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate type
|
||||||
|
const validTypes = Object.values(CMS_TYPES);
|
||||||
|
if (!validTypes.includes(type)) {
|
||||||
|
router.push("/cms/content");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (data: ContentUpdateInput) => {
|
||||||
|
try {
|
||||||
|
const fileInput = document.getElementById("image") as HTMLInputElement;
|
||||||
|
if (fileInput?.files?.[0]) {
|
||||||
|
data.image = fileInput.files[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateMutation.mutateAsync({ id, data });
|
||||||
|
toast({
|
||||||
|
title: "Content updated",
|
||||||
|
description: "Content has been updated successfully",
|
||||||
|
});
|
||||||
|
router.push(`/cms/content/${type}`);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Update failed",
|
||||||
|
description: "Failed to update content",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Edit {CMS_TYPE_LABELS[type]}</h1>
|
||||||
|
<p className="text-muted-foreground">Update {CMS_TYPE_LABELS[type].toLowerCase()} content</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Content Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Title</Label>
|
||||||
|
<Input id="title" {...register("title")} />
|
||||||
|
{errors.title && (
|
||||||
|
<p className="text-sm text-destructive">{errors.title.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="corp">Corp Type</Label>
|
||||||
|
<select
|
||||||
|
id="corp"
|
||||||
|
{...register("corp")}
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">Select corp type</option>
|
||||||
|
{Object.values(CORP_TYPES).map((corpType) => (
|
||||||
|
<option key={corpType} value={corpType}>
|
||||||
|
{CORP_TYPE_LABELS[corpType]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Input id="description" {...register("description")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="targetUrl">Target URL</Label>
|
||||||
|
<Input id="targetUrl" {...register("targetUrl")} />
|
||||||
|
{errors.targetUrl && (
|
||||||
|
<p className="text-sm text-destructive">{errors.targetUrl.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="image">Image (leave empty to keep current)</Label>
|
||||||
|
<Input id="image" type="file" accept="image/*" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" disabled={updateMutation.isPending}>
|
||||||
|
{updateMutation.isPending ? "Updating..." : "Update Content"}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { DataTable } from "@/components/data-table/data-table";
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { useContentList, useDeleteContent } from "@/modules/cms/content/hooks";
|
||||||
|
import { Content, CmsType } from "@/modules/cms/content/schemas";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { MoreHorizontal, Plus, Pencil, Trash, ArrowLeft } from "lucide-react";
|
||||||
|
import { formatDateTime } from "@/lib/utils";
|
||||||
|
import { CMS_TYPES, CMS_TYPE_LABELS, CORP_TYPE_LABELS } from "@/config/constants";
|
||||||
|
|
||||||
|
export default function ContentTypePage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const type = params.type as CmsType;
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useContentList(type);
|
||||||
|
const deleteMutation = useDeleteContent();
|
||||||
|
|
||||||
|
const validTypes = Object.values(CMS_TYPES);
|
||||||
|
if (!validTypes.includes(type)) {
|
||||||
|
router.push("/cms/content");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(deleteId);
|
||||||
|
toast({
|
||||||
|
title: "Content deleted",
|
||||||
|
description: "Content has been deleted successfully",
|
||||||
|
});
|
||||||
|
setDeleteId(null);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Delete failed",
|
||||||
|
description: "Failed to delete content",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
setDeleteId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<Content>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "Title_APC",
|
||||||
|
header: "Title",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "CorpType_APC",
|
||||||
|
header: "Corp",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{CORP_TYPE_LABELS[row.original.CorpType_APC] || row.original.CorpType_APC}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "TargetUrl_APC",
|
||||||
|
header: "Target URL",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="max-w-[200px] truncate">
|
||||||
|
{row.original.TargetUrl_APC || "-"}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "CreatedAt_APC",
|
||||||
|
header: "Created",
|
||||||
|
cell: ({ row }) => formatDateTime(row.original.CreatedAt_APC),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => router.push(`/cms/content/${type}/edit/${row.original.UUID_APC}`)}
|
||||||
|
>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setDeleteId(row.original.UUID_APC)}
|
||||||
|
className="text-destructive"
|
||||||
|
>
|
||||||
|
<Trash className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents = data?.data || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => router.push("/cms/content")}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">{CMS_TYPE_LABELS[type]}</h1>
|
||||||
|
<p className="text-muted-foreground">Manage {CMS_TYPE_LABELS[type].toLowerCase()} content</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => router.push(`/cms/content/${type}/create`)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create {CMS_TYPE_LABELS[type]}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{contents.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={`No ${CMS_TYPE_LABELS[type].toLowerCase()} yet`}
|
||||||
|
description={`Create your first ${CMS_TYPE_LABELS[type].toLowerCase()} content`}
|
||||||
|
action={
|
||||||
|
<Button onClick={() => router.push(`/cms/content/${type}/create`)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create {CMS_TYPE_LABELS[type]}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable columns={columns} data={contents} searchKey="Title_APC" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Content</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete this content? This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CMS_TYPES, CMS_TYPE_LABELS } from "@/config/constants";
|
||||||
|
import { FileText, Sparkles, Image, Layout } from "lucide-react";
|
||||||
|
|
||||||
|
const contentTypes = [
|
||||||
|
{
|
||||||
|
type: CMS_TYPES.SPLASH,
|
||||||
|
label: CMS_TYPE_LABELS.splash,
|
||||||
|
description: "Splash screen content for app launch",
|
||||||
|
icon: Sparkles,
|
||||||
|
color: "text-purple-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: CMS_TYPES.PROMO,
|
||||||
|
label: CMS_TYPE_LABELS.promo,
|
||||||
|
description: "Promotional content and offers",
|
||||||
|
icon: Image,
|
||||||
|
color: "text-blue-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: CMS_TYPES.ARTICLE,
|
||||||
|
label: CMS_TYPE_LABELS.article,
|
||||||
|
description: "Articles and blog posts",
|
||||||
|
icon: FileText,
|
||||||
|
color: "text-green-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: CMS_TYPES.BANNER,
|
||||||
|
label: CMS_TYPE_LABELS.banner,
|
||||||
|
description: "Banner advertisements",
|
||||||
|
icon: Layout,
|
||||||
|
color: "text-orange-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: CMS_TYPES.FLOATING_WIDGET,
|
||||||
|
label: CMS_TYPE_LABELS.floatingWidget,
|
||||||
|
description: "Floating widget content",
|
||||||
|
icon: Image,
|
||||||
|
color: "text-pink-500",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ContentOverviewPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">CMS Content Management</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage different types of content for your applications
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{contentTypes.map((item) => (
|
||||||
|
<Card key={item.type} className="hover:shadow-lg transition-shadow cursor-pointer">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<item.icon className={`h-8 w-8 ${item.color}`} />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push(`/cms/content/${item.type}`)}
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CardTitle>{item.label}</CardTitle>
|
||||||
|
<CardDescription>{item.description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Click "Manage" to view and edit {item.label.toLowerCase()} content
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <DashboardLayout>{children}</DashboardLayout>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { useGenerateResponse } from "@/modules/openai/hooks";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Brain, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
export default function OpenAIPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const generateMutation = useGenerateResponse();
|
||||||
|
const [response, setResponse] = useState<any>(null);
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
const samplePrompt = {
|
||||||
|
prompt: {
|
||||||
|
user_id: "USR78910",
|
||||||
|
recent_activities: [
|
||||||
|
{
|
||||||
|
type: "login",
|
||||||
|
platform: "Android",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await generateMutation.mutateAsync(samplePrompt);
|
||||||
|
setResponse(result);
|
||||||
|
toast({ title: "Response generated", description: "AI response generated successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
toast({ title: "Generation failed", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">OpenAI Integration</h1>
|
||||||
|
<p className="text-muted-foreground">Generate AI-powered responses</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>AI Response Generator</CardTitle>
|
||||||
|
<CardDescription>Generate intelligent responses using OpenAI</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Button onClick={handleGenerate} disabled={generateMutation.isPending}>
|
||||||
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
|
{generateMutation.isPending ? "Generating..." : "Generate Response"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{response && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>AI Response</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<pre className="rounded-lg bg-muted p-4 text-sm overflow-auto">
|
||||||
|
{JSON.stringify(response, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,633 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Activity, Database, FileText, Key, Users, Image as ImageIcon, Megaphone, Layers, TrendingUp, PieChart, BarChart3, Calendar, Target, Send, CheckCircle2, XCircle } from "lucide-react";
|
||||||
|
import { useDashboardStats } from "@/modules/admin/hooks";
|
||||||
|
import { useCampaignAnalytics } from "@/modules/campaigns/hooks";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
PieChart as RechartsPie,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
const COLORS = ['#404040', '#606060', '#808080', '#a0a0a0', '#505050', '#707070', '#909090'];
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { data, isLoading } = useDashboardStats();
|
||||||
|
const { data: campaignAnalyticsData, isLoading: isCampaignLoading } = useCampaignAnalytics();
|
||||||
|
const stats = data?.data?.summary;
|
||||||
|
const charts = data?.data?.charts;
|
||||||
|
const campaignAnalytics = campaignAnalyticsData?.data;
|
||||||
|
|
||||||
|
if (isLoading || isCampaignLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||||
|
<p className="text-muted-foreground">Loading dashboard...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentByTypeData = charts?.contentByType.labels.map((label: string, index: number) => ({
|
||||||
|
name: label,
|
||||||
|
value: charts.contentByType.data[index],
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
const contentByCorpData = charts?.contentByCorpType.labels.map((label: string, index: number) => ({
|
||||||
|
name: label,
|
||||||
|
value: charts.contentByCorpType.data[index],
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
const campaignStatusData = charts?.campaignStatus.labels.map((label: string, index: number) => ({
|
||||||
|
name: label,
|
||||||
|
value: charts.campaignStatus.data[index],
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
const userTrendData = charts?.userRegistrationTrend.labels.map((label: string, index: number) => ({
|
||||||
|
date: label,
|
||||||
|
users: charts.userRegistrationTrend.data[index],
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
const timelineData = Object.entries(charts?.contentCreationTimeline || {}).map(([date, types]) => {
|
||||||
|
const typedTypes = types as Record<string, number>;
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
...typedTypes,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 pb-8">
|
||||||
|
{/* Hero Header - Windows 10 Style */}
|
||||||
|
<div className="relative overflow-hidden rounded-lg glass p-8 border">
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight mb-2">Admin CSA Dashboard</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Enterprise Management & Analytics Platform
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 glass-dark px-3 py-1.5 rounded text-xs">
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-foreground"></div>
|
||||||
|
<span>System Online</span>
|
||||||
|
</div>
|
||||||
|
<div className="glass-dark px-3 py-1.5 rounded text-xs">
|
||||||
|
{new Date().toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute right-8 top-1/2 -translate-y-1/2 opacity-5">
|
||||||
|
<BarChart3 className="h-24 w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Metrics - Clean Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card className="glass border hover:border-foreground/20 transition-colors">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Content</CardTitle>
|
||||||
|
<div className="p-2 bg-muted rounded">
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-semibold">{stats?.content.total || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Across all platforms</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="glass border hover:border-foreground/20 transition-colors">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Active Users</CardTitle>
|
||||||
|
<div className="p-2 bg-muted rounded">
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-semibold">{stats?.infra.users || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Registered users</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="glass border hover:border-foreground/20 transition-colors">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Campaigns</CardTitle>
|
||||||
|
<div className="p-2 bg-muted rounded">
|
||||||
|
<Megaphone className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-semibold">{stats?.campaigns || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Active campaigns</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="glass border hover:border-foreground/20 transition-colors">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">API Keys</CardTitle>
|
||||||
|
<div className="p-2 bg-muted rounded">
|
||||||
|
<Key className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-semibold">{stats?.infra.apiKeys || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Active tokens</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Campaign Analytics Section */}
|
||||||
|
{campaignAnalytics && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 mt-8">
|
||||||
|
<Megaphone className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<h2 className="text-2xl font-semibold">Campaign Analytics</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Campaign Summary Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card className="glass border hover:border-foreground/20 transition-colors">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Campaigns</CardTitle>
|
||||||
|
<div className="p-2 bg-muted rounded">
|
||||||
|
<Target className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-semibold">{campaignAnalytics.summary.campaigns.total}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{campaignAnalytics.summary.campaigns.upcomingCount} upcoming
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="glass border hover:border-foreground/20 transition-colors">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Target Users</CardTitle>
|
||||||
|
<div className="p-2 bg-muted rounded">
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-semibold">{campaignAnalytics.summary.delivery.totalTargetUsers}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Total reached</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="glass border hover:border-foreground/20 transition-colors">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Delivered</CardTitle>
|
||||||
|
<div className="p-2 bg-muted rounded">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-semibold text-green-600">{campaignAnalytics.summary.delivery.totalDelivered}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{campaignAnalytics.summary.delivery.overallDeliveryRate} success rate
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="glass border hover:border-foreground/20 transition-colors">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Failed</CardTitle>
|
||||||
|
<div className="p-2 bg-muted rounded">
|
||||||
|
<XCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-semibold text-red-600">{campaignAnalytics.summary.delivery.totalFailed}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Failed deliveries</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Campaign Charts */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card className="glass border">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PieChart className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">Campaign Status</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Distribution by status</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<RechartsPie>
|
||||||
|
<Pie
|
||||||
|
data={campaignAnalytics.charts.statusDistribution.labels.map((label: string, index: number) => ({
|
||||||
|
name: label,
|
||||||
|
value: campaignAnalytics.charts.statusDistribution.data[index],
|
||||||
|
}))}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
label={({ name, value }: any) => `${name}: ${value}`}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{campaignAnalytics.charts.statusDistribution.labels.map((_: any, index: number) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
</RechartsPie>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass border">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">Delivery Status</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Success vs failure rate</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart
|
||||||
|
data={campaignAnalytics.charts.deliveryStatusDistribution.labels.map((label: string, index: number) => ({
|
||||||
|
name: label,
|
||||||
|
value: campaignAnalytics.charts.deliveryStatusDistribution.data[index],
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.1} />
|
||||||
|
<XAxis dataKey="name" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="value" fill="hsl(var(--foreground))" opacity={0.7} radius={[8, 8, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass border">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">Creation Trend</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Campaigns created over time</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<AreaChart
|
||||||
|
data={campaignAnalytics.charts.creationTrend.labels.map((label: string, index: number) => ({
|
||||||
|
date: label,
|
||||||
|
count: campaignAnalytics.charts.creationTrend.data[index],
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorCampaigns" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="hsl(var(--foreground))" stopOpacity={0.3}/>
|
||||||
|
<stop offset="95%" stopColor="hsl(var(--foreground))" stopOpacity={0}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.1} />
|
||||||
|
<XAxis dataKey="date" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Area type="monotone" dataKey="count" stroke="hsl(var(--foreground))" strokeWidth={2} fillOpacity={1} fill="url(#colorCampaigns)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Top Performing Campaign */}
|
||||||
|
{campaignAnalytics.topPerforming.length > 0 && (
|
||||||
|
<Card className="glass border">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Target className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">Top Performing</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Best campaign by delivery rate</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{campaignAnalytics.topPerforming.map((campaign: any) => (
|
||||||
|
<div key={campaign.id} className="border rounded-lg p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">{campaign.title}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{new Date(campaign.date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold">{campaign.targetUsers}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Target</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold text-green-600">{campaign.successCount}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Success</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold text-red-600">{campaign.failureCount}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Failed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t">
|
||||||
|
<span className="text-sm text-muted-foreground">Delivery Rate</span>
|
||||||
|
<span className={`text-lg font-bold ${
|
||||||
|
campaign.deliveryRate >= 90 ? 'text-green-600' :
|
||||||
|
campaign.deliveryRate >= 70 ? 'text-yellow-600' :
|
||||||
|
'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{campaign.deliveryRate.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content Management Section */}
|
||||||
|
<div className="flex items-center gap-2 mt-8">
|
||||||
|
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<h2 className="text-2xl font-semibold">Content Management</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card className="glass border">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PieChart className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">Content Distribution</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Content breakdown by type</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<RechartsPie>
|
||||||
|
<Pie
|
||||||
|
data={contentByTypeData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
label={({ name, percent }: any) => `${name} ${((percent || 0) * 100).toFixed(0)}%`}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{contentByTypeData.map((_entry: any, index: number) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</RechartsPie>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass border">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">Content by Corporation</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Content distribution across corps</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={contentByCorpData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.1} />
|
||||||
|
<XAxis dataKey="name" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="value" fill="hsl(var(--foreground))" opacity={0.7} radius={[8, 8, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass border">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">User Registration Trend</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>New user registrations over time</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<AreaChart data={userTrendData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorUsers" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="hsl(var(--foreground))" stopOpacity={0.3}/>
|
||||||
|
<stop offset="95%" stopColor="hsl(var(--foreground))" stopOpacity={0}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.1} />
|
||||||
|
<XAxis dataKey="date" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Area type="monotone" dataKey="users" stroke="hsl(var(--foreground))" strokeWidth={2} fillOpacity={1} fill="url(#colorUsers)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass border">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Megaphone className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">Campaign Status</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Current campaign distribution</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<RechartsPie>
|
||||||
|
<Pie
|
||||||
|
data={campaignStatusData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
label={({ name, value }: any) => `${name}: ${value}`}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{campaignStatusData.map((_entry: any, index: number) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
</RechartsPie>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{timelineData.length > 0 && (
|
||||||
|
<Card className="glass border">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">Content Creation Timeline</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Content created over time by type</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={350}>
|
||||||
|
<BarChart data={timelineData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.1} />
|
||||||
|
<XAxis dataKey="date" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="article" stackId="a" fill="#404040" />
|
||||||
|
<Bar dataKey="splash" stackId="a" fill="#606060" />
|
||||||
|
<Bar dataKey="promo" stackId="a" fill="#808080" />
|
||||||
|
<Bar dataKey="banner" stackId="a" fill="#a0a0a0" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{charts?.contentMatrix && charts.contentMatrix.length > 0 && (
|
||||||
|
<Card className="glass border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Content Matrix</CardTitle>
|
||||||
|
<CardDescription>Content distribution by type and corporation</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-lg border border-border/50">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/30">
|
||||||
|
<th className="p-3 text-left font-medium text-sm">Content Type</th>
|
||||||
|
<th className="p-3 text-left font-medium text-sm">Corporation</th>
|
||||||
|
<th className="p-3 text-right font-medium text-sm">Count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{charts.contentMatrix.map((item: any, index: number) => (
|
||||||
|
<tr key={index} className="border-b last:border-0 hover:bg-muted/20 transition-colors">
|
||||||
|
<td className="p-3 capitalize text-sm">{item.type}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Badge variant="outline" className="capitalize text-xs">{item.corp}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right font-mono text-sm">{item.count}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className="glass border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Database className="h-5 w-5 text-muted-foreground" />
|
||||||
|
Infrastructure Overview
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>System resources and services</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="flex items-center gap-4 rounded-lg border border-border/50 p-4 hover:border-foreground/20 transition-colors">
|
||||||
|
<div className="rounded bg-muted p-3">
|
||||||
|
<Key className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-semibold">{stats?.infra.apiKeys || 0}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">API Keys</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 rounded-lg border border-border/50 p-4 hover:border-foreground/20 transition-colors">
|
||||||
|
<div className="rounded bg-muted p-3">
|
||||||
|
<Database className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-semibold">{stats?.infra.buckets || 0}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Buckets</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 rounded-lg border border-border/50 p-4 hover:border-foreground/20 transition-colors">
|
||||||
|
<div className="rounded bg-muted p-3">
|
||||||
|
<Users className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-semibold">{stats?.infra.users || 0}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Users</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||||
|
Content Type Breakdown
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Distribution of content across different types</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<div className="rounded-lg border border-border/50 bg-muted/30 p-4 hover:border-foreground/20 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<span className="text-2xl font-semibold">{stats?.content.splash || 0}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm font-medium">Splash</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border/50 bg-muted/30 p-4 hover:border-foreground/20 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Megaphone className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<span className="text-2xl font-semibold">{stats?.content.promo || 0}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm font-medium">Promo</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border/50 bg-muted/30 p-4 hover:border-foreground/20 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<span className="text-2xl font-semibold">{stats?.content.article || 0}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm font-medium">Article</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border/50 bg-muted/30 p-4 hover:border-foreground/20 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Layers className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<span className="text-2xl font-semibold">{stats?.content.banner || 0}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm font-medium">Banner</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border/50 bg-muted/30 p-4 hover:border-foreground/20 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Activity className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<span className="text-2xl font-semibold">{stats?.content.floatingWidget || 0}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm font-medium">Widget</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { DataTable } from "@/components/data-table/data-table";
|
||||||
|
import { useUsers, useSetupUserToken } from "@/modules/users/hooks";
|
||||||
|
import { userTokenSchema, type UserTokenInput, User } from "@/modules/users/schemas";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Users as UsersIcon, Eye, Copy } from "lucide-react";
|
||||||
|
import { formatDateTime } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { data, isLoading } = useUsers();
|
||||||
|
const setupMutation = useSetupUserToken();
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
|
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<UserTokenInput>({
|
||||||
|
resolver: zodResolver(userTokenSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: UserTokenInput) => {
|
||||||
|
try {
|
||||||
|
await setupMutation.mutateAsync(data);
|
||||||
|
toast({ title: "Token setup successful", description: "User token has been configured" });
|
||||||
|
reset();
|
||||||
|
} catch (error) {
|
||||||
|
toast({ title: "Setup failed", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast({ title: "Copied to clipboard", description: "Text has been copied successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
toast({ title: "Copy failed", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenDetail = (user: User) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setIsDetailOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<User>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "UserID_UT",
|
||||||
|
header: "User ID",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="font-medium">{row.original.UserID_UT}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "Token_UT",
|
||||||
|
header: "Token",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="font-mono text-sm max-w-[300px] truncate">
|
||||||
|
{row.original.Token_UT}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "CreatedAt_UT",
|
||||||
|
header: "Created",
|
||||||
|
cell: ({ row }) => formatDateTime(row.original.CreatedAt_UT),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "UpdatedAt_UT",
|
||||||
|
header: "Updated",
|
||||||
|
cell: ({ row }) => formatDateTime(row.original.UpdatedAt_UT),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleOpenDetail(row.original)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const users = data?.data || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Users Management</h1>
|
||||||
|
<p className="text-muted-foreground">Manage user tokens and settings</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<UsersIcon className="h-5 w-5" />
|
||||||
|
All Users
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Total: {users.length} users
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div>Loading...</div>
|
||||||
|
) : (
|
||||||
|
<DataTable columns={columns} data={users} searchKey="UserID_UT" />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Setup User Token</CardTitle>
|
||||||
|
<CardDescription>Configure user authentication token</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="userID">User ID</Label>
|
||||||
|
<Input id="userID" {...register("userID")} placeholder="C005ZS80" />
|
||||||
|
{errors.userID && (
|
||||||
|
<p className="text-sm text-destructive">{errors.userID.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="token">Token</Label>
|
||||||
|
<Input
|
||||||
|
id="token"
|
||||||
|
{...register("token")}
|
||||||
|
placeholder="Firebase Cloud Messaging Token"
|
||||||
|
/>
|
||||||
|
{errors.token && (
|
||||||
|
<p className="text-sm text-destructive">{errors.token.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={setupMutation.isPending}>
|
||||||
|
{setupMutation.isPending ? "Setting up..." : "Setup Token"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={isDetailOpen} onOpenChange={setIsDetailOpen}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>User Details</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Complete user information and token
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{selectedUser && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">User ID</label>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<p className="text-sm font-mono">{selectedUser.UserID_UT}</p>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => handleCopy(selectedUser.UserID_UT)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">UUID</label>
|
||||||
|
<p className="mt-1 text-sm font-mono truncate">{selectedUser.UUID_UT}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">FCM Token</label>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<div className="flex-1 rounded-lg bg-muted p-3 font-mono text-xs break-all">
|
||||||
|
{selectedUser.Token_UT}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleCopy(selectedUser.Token_UT)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">Created At</label>
|
||||||
|
<p className="mt-1 text-sm">{formatDateTime(selectedUser.CreatedAt_UT)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">Updated At</label>
|
||||||
|
<p className="mt-1 text-sm">{formatDateTime(selectedUser.UpdatedAt_UT)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
/* Light theme - Off-white, Gray tones */
|
||||||
|
--background: 40 20% 97%;
|
||||||
|
/* Off-white/Ivory */
|
||||||
|
--foreground: 0 0% 10%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 10%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 10%;
|
||||||
|
--primary: 0 0% 20%;
|
||||||
|
/* Dark Gray */
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 0 0% 96%;
|
||||||
|
--secondary-foreground: 0 0% 10%;
|
||||||
|
--muted: 0 0% 94%;
|
||||||
|
--muted-foreground: 0 0% 45%;
|
||||||
|
--accent: 0 0% 35%;
|
||||||
|
/* Medium Gray */
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 0% 30%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 0 0% 88%;
|
||||||
|
--input: 0 0% 88%;
|
||||||
|
--ring: 0 0% 20%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
/* Dark theme - Black and Grays */
|
||||||
|
--background: 0 0% 6%;
|
||||||
|
/* Near Black */
|
||||||
|
--foreground: 0 0% 95%;
|
||||||
|
--card: 0 0% 10%;
|
||||||
|
--card-foreground: 0 0% 95%;
|
||||||
|
--popover: 0 0% 10%;
|
||||||
|
--popover-foreground: 0 0% 95%;
|
||||||
|
--primary: 0 0% 85%;
|
||||||
|
/* Light Gray */
|
||||||
|
--primary-foreground: 0 0% 10%;
|
||||||
|
--secondary: 0 0% 15%;
|
||||||
|
--secondary-foreground: 0 0% 95%;
|
||||||
|
--muted: 0 0% 15%;
|
||||||
|
--muted-foreground: 0 0% 60%;
|
||||||
|
--accent: 0 0% 70%;
|
||||||
|
/* Medium Light Gray */
|
||||||
|
--accent-foreground: 0 0% 10%;
|
||||||
|
--destructive: 0 0% 70%;
|
||||||
|
--destructive-foreground: 0 0% 10%;
|
||||||
|
--border: 0 0% 18%;
|
||||||
|
--input: 0 0% 18%;
|
||||||
|
--ring: 0 0% 70%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
background: linear-gradient(to bottom right, hsl(var(--background)), hsl(0 0% 8%));
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.glass {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-dark {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow {
|
||||||
|
box-shadow:
|
||||||
|
0 0 20px rgba(255, 255, 255, 0.2),
|
||||||
|
0 0 40px rgba(255, 255, 255, 0.15),
|
||||||
|
0 0 60px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-hover:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 0 30px rgba(255, 255, 255, 0.3),
|
||||||
|
0 0 60px rgba(255, 255, 255, 0.2),
|
||||||
|
0 0 90px rgba(255, 255, 255, 0.15);
|
||||||
|
transition: box-shadow 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-dark {
|
||||||
|
box-shadow:
|
||||||
|
0 0 20px rgba(0, 0, 0, 0.3),
|
||||||
|
0 0 40px rgba(0, 0, 0, 0.2),
|
||||||
|
0 0 60px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-gray {
|
||||||
|
box-shadow:
|
||||||
|
0 0 20px rgba(150, 150, 150, 0.3),
|
||||||
|
0 0 40px rgba(150, 150, 150, 0.2),
|
||||||
|
0 0 60px rgba(150, 150, 150, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-white {
|
||||||
|
box-shadow:
|
||||||
|
0 0 20px rgba(255, 255, 255, 0.25),
|
||||||
|
0 0 40px rgba(255, 255, 255, 0.15),
|
||||||
|
0 0 60px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-glow {
|
||||||
|
animation: glow-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-pulse {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 20px rgba(255, 255, 255, 0.2),
|
||||||
|
0 0 40px rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 30px rgba(255, 255, 255, 0.3),
|
||||||
|
0 0 60px rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-gradient {
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
rgba(255, 255, 255, 0.08) 0%,
|
||||||
|
rgba(150, 150, 150, 0.05) 50%,
|
||||||
|
rgba(255, 255, 255, 0.08) 100%);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation: liquid-flow 15s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes liquid-flow {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-gradient {
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-gradient::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -2px;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 2px;
|
||||||
|
background: linear-gradient(135deg, #fff, #888, #fff);
|
||||||
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.1),
|
||||||
|
transparent);
|
||||||
|
animation: shimmer 3s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
left: -100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin-smooth {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-spin-smooth {
|
||||||
|
animation: spin-smooth 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { ThemeProvider } from "@/providers/theme-provider";
|
||||||
|
import { QueryProvider } from "@/providers/query-provider";
|
||||||
|
import { AuthProvider } from "@/providers/auth-provider";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Admin CSA - Enterprise Dashboard",
|
||||||
|
description: "Modern admin dashboard for CSA management",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<body className={inter.className}>
|
||||||
|
<ThemeProvider>
|
||||||
|
<QueryProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</AuthProvider>
|
||||||
|
</QueryProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
import { useLogin } from "@/modules/admin/hooks";
|
||||||
|
import { loginSchema, type LoginInput } from "@/modules/admin/schemas";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { login: authLogin } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const loginMutation = useLogin();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginInput>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: LoginInput) => {
|
||||||
|
try {
|
||||||
|
const response = await loginMutation.mutateAsync(data);
|
||||||
|
|
||||||
|
if (response.data.token) {
|
||||||
|
authLogin(response.data.token, { email: response.data.admin.email, username: response.data.admin.name });
|
||||||
|
toast({
|
||||||
|
title: "Login successful",
|
||||||
|
description: "Welcome back!",
|
||||||
|
});
|
||||||
|
// router.push is handled by authLogin
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
title: "Login failed",
|
||||||
|
description: error.response?.data?.message || "Invalid credentials",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl font-bold text-center text-white">
|
||||||
|
Admin CSA
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-center">
|
||||||
|
Enter your credentials to access the dashboard
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="admin@csa.id"
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...register("password")}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={loginMutation.isPending}
|
||||||
|
>
|
||||||
|
{loginMutation.isPending ? "Signing in..." : "Sign In"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link href="/register" className="text-primary hover:underline">
|
||||||
|
Register
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { useRegister } from "@/modules/admin/hooks";
|
||||||
|
import { registerSchema, type RegisterInput } from "@/modules/admin/schemas";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const registerMutation = useRegister();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<RegisterInput>({
|
||||||
|
resolver: zodResolver(registerSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: RegisterInput) => {
|
||||||
|
try {
|
||||||
|
await registerMutation.mutateAsync(data);
|
||||||
|
toast({
|
||||||
|
title: "Registration successful",
|
||||||
|
description: "You can now login with your credentials",
|
||||||
|
});
|
||||||
|
router.push("/login");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
title: "Registration failed",
|
||||||
|
description: error.response?.data?.message || "Something went wrong",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background via-background to-primary/5 p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl font-bold text-center bg-gradient-to-r from-primary to-purple-600 bg-clip-text text-transparent">
|
||||||
|
Create Account
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-center">
|
||||||
|
Register for Admin CSA dashboard access
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
placeholder="ADMIN CSA"
|
||||||
|
{...register("username")}
|
||||||
|
/>
|
||||||
|
{errors.username && (
|
||||||
|
<p className="text-sm text-destructive">{errors.username.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="admin@csa.id"
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...register("password")}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={registerMutation.isPending}
|
||||||
|
>
|
||||||
|
{registerMutation.isPending ? "Creating account..." : "Create Account"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link href="/login" className="text-primary hover:underline">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Table } from "@tanstack/react-table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
|
||||||
|
|
||||||
|
interface DataTablePaginationProps<TData> {
|
||||||
|
table: Table<TData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTablePagination<TData>({
|
||||||
|
table,
|
||||||
|
}: DataTablePaginationProps<TData>) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<div className="flex-1 text-sm text-muted-foreground">
|
||||||
|
{table.getFilteredSelectedRowModel().rows.length > 0 && (
|
||||||
|
<span>
|
||||||
|
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||||
|
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||||
|
{table.getPageCount()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.setPageIndex(0)}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<ChevronsLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<ChevronsRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Table } from "@tanstack/react-table";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
|
interface DataTableToolbarProps<TData> {
|
||||||
|
table: Table<TData>;
|
||||||
|
searchKey?: string;
|
||||||
|
onSearch?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableToolbar<TData>({
|
||||||
|
table,
|
||||||
|
searchKey,
|
||||||
|
onSearch,
|
||||||
|
}: DataTableToolbarProps<TData>) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-1 items-center space-x-2">
|
||||||
|
{searchKey && (
|
||||||
|
<div className="relative w-full max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={`Search ${searchKey}...`}
|
||||||
|
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
table.getColumn(searchKey)?.setFilterValue(value);
|
||||||
|
onSearch?.(value);
|
||||||
|
}}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
SortingState,
|
||||||
|
ColumnFiltersState,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { DataTablePagination } from "./data-table-pagination";
|
||||||
|
import { DataTableToolbar } from "./data-table-toolbar";
|
||||||
|
|
||||||
|
interface DataTableProps<TData, TValue> {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
searchKey?: string;
|
||||||
|
onSearch?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<TData, TValue>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
searchKey,
|
||||||
|
onSearch,
|
||||||
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||||
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<DataTableToolbar
|
||||||
|
table={table}
|
||||||
|
searchKey={searchKey}
|
||||||
|
onSearch={onSearch}
|
||||||
|
/>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
|
No results.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<DataTablePagination table={table} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { FileQuestion } from "lucide-react";
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ title, description, action }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
||||||
|
<FileQuestion className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-2 max-w-sm">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{action && <div className="mt-4">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Sidebar } from "./sidebar";
|
||||||
|
import { Topbar } from "./topbar";
|
||||||
|
|
||||||
|
export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<Topbar />
|
||||||
|
<main className="flex-1 overflow-y-auto bg-background p-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ROUTES } from "@/config/constants";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Key,
|
||||||
|
FileText,
|
||||||
|
Database,
|
||||||
|
Users,
|
||||||
|
Brain,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Megaphone,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: "Dashboard", href: ROUTES.HOME, icon: LayoutDashboard },
|
||||||
|
{ name: "API Management", href: ROUTES.API_MANAGEMENT, icon: Key },
|
||||||
|
{ name: "Campaign Management", href: ROUTES.CAMPAIGNS, icon: Megaphone },
|
||||||
|
{ name: "CMS Content", href: ROUTES.CMS_CONTENT, icon: FileText },
|
||||||
|
{ name: "CMS Buckets", href: ROUTES.CMS_BUCKETS, icon: Database },
|
||||||
|
{ name: "Users", href: ROUTES.USERS, icon: Users },
|
||||||
|
{ name: "OpenAI", href: ROUTES.OPENAI, icon: Brain },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-screen flex-col border-r bg-card transition-all duration-300",
|
||||||
|
collapsed ? "w-16" : "w-64"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-16 items-center justify-between border-b px-4">
|
||||||
|
{!collapsed && (
|
||||||
|
<h1 className="text-xl font-bold text-white">
|
||||||
|
Admin CSA
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 space-y-1 p-2 overflow-y-auto">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all hover:bg-accent",
|
||||||
|
isActive
|
||||||
|
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
collapsed && "justify-center"
|
||||||
|
)}
|
||||||
|
title={collapsed ? item.name : undefined}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5 flex-shrink-0" />
|
||||||
|
{!collapsed && <span>{item.name}</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Moon, Sun, LogOut, User, Search } from "lucide-react";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function Topbar() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const initials = user?.username
|
||||||
|
? user.username
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
: user?.email?.[0].toUpperCase() || "A";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-16 items-center gap-4 border-b bg-card px-6">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative w-full max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search..."
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
|
>
|
||||||
|
{!mounted ? (
|
||||||
|
<Sun className="h-5 w-5" />
|
||||||
|
) : theme === "dark" ? (
|
||||||
|
<Sun className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Moon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarFallback className="bg-primary text-primary-foreground">
|
||||||
|
{initials}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<p className="text-sm font-medium">{user?.username || "Admin"}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<User className="mr-2 h-4 w-4" />
|
||||||
|
Profile
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={logout}>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
Logout
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"mt-2 sm:mt-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||||
|
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> { }
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { Check, ChevronRight } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border glass p-1 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border glass shadow-md",
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-muted hover:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { }
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
);
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Table.displayName = "Table";
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
));
|
||||||
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableFooter.displayName = "TableFooter";
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCaption.displayName = "TableCaption";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider;
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border bg-background text-foreground",
|
||||||
|
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
));
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
Toast,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
} from "@/components/ui/toast";
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && (
|
||||||
|
<ToastDescription>{description}</ToastDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
export const APP_NAME = "Admin CSA";
|
||||||
|
export const APP_DESCRIPTION = "Enterprise Admin Dashboard";
|
||||||
|
|
||||||
|
export const ROUTES = {
|
||||||
|
HOME: "/",
|
||||||
|
LOGIN: "/login",
|
||||||
|
REGISTER: "/register",
|
||||||
|
API_MANAGEMENT: "/api-management",
|
||||||
|
CMS_CONTENT: "/cms/content",
|
||||||
|
CMS_BUCKETS: "/cms/buckets",
|
||||||
|
USERS: "/users",
|
||||||
|
OPENAI: "/openai",
|
||||||
|
CAMPAIGNS: "/campaigns",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const QUERY_KEYS = {
|
||||||
|
ADMIN: "admin",
|
||||||
|
API_KEYS: "api-keys",
|
||||||
|
CMS_CONTENT: "cms-content",
|
||||||
|
CMS_BUCKETS: "cms-buckets",
|
||||||
|
USERS: "users",
|
||||||
|
OPENAI: "openai",
|
||||||
|
CAMPAIGNS: "campaigns",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const CMS_TYPES = {
|
||||||
|
SPLASH: "splash",
|
||||||
|
PROMO: "promo",
|
||||||
|
ARTICLE: "article",
|
||||||
|
BANNER: "banner",
|
||||||
|
FLOATING_WIDGET: "floatingWidget",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const CORP_TYPES = {
|
||||||
|
WALANJA: "walanja",
|
||||||
|
SIMAYA: "simaya",
|
||||||
|
CIFO: "cifo",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const CMS_TYPE_LABELS: Record<string, string> = {
|
||||||
|
splash: "Splash Screen",
|
||||||
|
promo: "Promo",
|
||||||
|
article: "Article",
|
||||||
|
banner: "Banner",
|
||||||
|
floatingWidget: "Floating Widget",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CORP_TYPE_LABELS: Record<string, string> = {
|
||||||
|
walanja: "Walanja",
|
||||||
|
simaya: "Simaya",
|
||||||
|
cifo: "CIFO",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
export const env = {
|
||||||
|
apiBaseUrl: process.env.NEXT_PUBLIC_API_BASE_URL || "",
|
||||||
|
apiKey: process.env.NEXT_PUBLIC_API_KEY || "",
|
||||||
|
endpoints: {
|
||||||
|
apiManagement: process.env.NEXT_PUBLIC_API_MANAGEMENT || "api-management",
|
||||||
|
cmsManagement: process.env.NEXT_PUBLIC_CMS_MANAGEMENT || "cms-management",
|
||||||
|
bucketManagement: process.env.NEXT_PUBLIC_BUCKET_MANAGEMENT || "bucket-management",
|
||||||
|
userManagement: process.env.NEXT_PUBLIC_USER_MANAGEMENT || "user-management",
|
||||||
|
adminManagement: process.env.NEXT_PUBLIC_ADMIN_MANAGEMENT || "admin-management",
|
||||||
|
campaignManagement: process.env.NEXT_PUBLIC_CAMPAIGN_MANAGEMENT || "campaign-management",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast";
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1;
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000;
|
||||||
|
|
||||||
|
type ToasterToast = {
|
||||||
|
id: string;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes;
|
||||||
|
type Action =
|
||||||
|
| { type: ActionType["ADD_TOAST"]; toast: ToasterToast }
|
||||||
|
| { type: ActionType["UPDATE_TOAST"]; toast: Partial<ToasterToast> }
|
||||||
|
| { type: ActionType["DISMISS_TOAST"]; toastId?: ToasterToast["id"] }
|
||||||
|
| { type: ActionType["REMOVE_TOAST"]; toastId?: ToasterToast["id"] };
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId);
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId: toastId,
|
||||||
|
});
|
||||||
|
}, TOAST_REMOVE_DELAY);
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action;
|
||||||
|
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId);
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = [];
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] };
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action);
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">;
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId();
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
});
|
||||||
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open: boolean) => {
|
||||||
|
if (!open) dismiss();
|
||||||
|
},
|
||||||
|
} as ToasterToast,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState);
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState);
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast };
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import axios from "axios";
|
||||||
|
import { env } from "@/config/env";
|
||||||
|
|
||||||
|
export const apiClient = axios.create({
|
||||||
|
baseURL: env.apiBaseUrl,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
config.headers["x-api-key"] = env.apiKey;
|
||||||
|
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string | Date) {
|
||||||
|
return new Date(date).toLocaleDateString("id-ID", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(date: string | Date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
// Use UTC methods to avoid timezone conversion
|
||||||
|
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||||
|
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const year = String(d.getUTCFullYear()).slice(-2);
|
||||||
|
const hours = String(d.getUTCHours()).padStart(2, '0');
|
||||||
|
const minutes = String(d.getUTCMinutes()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${day}/${month}/${year} ${hours}.${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(str: string, length: number) {
|
||||||
|
return str.length > length ? str.substring(0, length) + "..." : str;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const publicRoutes = ["/login", "/register"];
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const token = request.cookies.get("token")?.value;
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
if (!token && !publicRoutes.includes(pathname)) {
|
||||||
|
return NextResponse.redirect(new URL("/login", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token && publicRoutes.includes(pathname)) {
|
||||||
|
return NextResponse.redirect(new URL("/", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { adminService } from "./services";
|
||||||
|
import type { LoginInput, RegisterInput } from "./schemas";
|
||||||
|
import { QUERY_KEYS } from "@/config/constants";
|
||||||
|
|
||||||
|
export const useLogin = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: LoginInput) => adminService.login(data),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRegister = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: RegisterInput) => adminService.register(data),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDashboardStats = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEYS.ADMIN, "stats"],
|
||||||
|
queryFn: adminService.getStats,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const registerSchema = z.object({
|
||||||
|
username: z.string().min(3, "Username must be at least 3 characters"),
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LoginInput = z.infer<typeof loginSchema>;
|
||||||
|
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||||
|
|
||||||
|
export interface ContentMatrix {
|
||||||
|
type: string;
|
||||||
|
corp: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentCreationTimeline {
|
||||||
|
[date: string]: {
|
||||||
|
[type: string]: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
summary: {
|
||||||
|
content: {
|
||||||
|
splash: number;
|
||||||
|
promo: number;
|
||||||
|
article: number;
|
||||||
|
banner: number;
|
||||||
|
floatingWidget: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
infra: {
|
||||||
|
buckets: number;
|
||||||
|
apiKeys: number;
|
||||||
|
users: number;
|
||||||
|
};
|
||||||
|
campaigns: number;
|
||||||
|
activities: number;
|
||||||
|
};
|
||||||
|
charts: {
|
||||||
|
contentByType: {
|
||||||
|
labels: string[];
|
||||||
|
data: number[];
|
||||||
|
};
|
||||||
|
contentByCorpType: {
|
||||||
|
labels: string[];
|
||||||
|
data: number[];
|
||||||
|
};
|
||||||
|
contentMatrix: ContentMatrix[];
|
||||||
|
contentCreationTimeline: ContentCreationTimeline;
|
||||||
|
campaignStatus: {
|
||||||
|
labels: string[];
|
||||||
|
data: number[];
|
||||||
|
};
|
||||||
|
activityTypes: {
|
||||||
|
labels: string[];
|
||||||
|
data: number[];
|
||||||
|
};
|
||||||
|
userRegistrationTrend: {
|
||||||
|
labels: string[];
|
||||||
|
data: number[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { apiClient } from "@/lib/api-client";
|
||||||
|
import { env } from "@/config/env";
|
||||||
|
import type { LoginInput, RegisterInput } from "./schemas";
|
||||||
|
|
||||||
|
export const adminService = {
|
||||||
|
login: async (data: LoginInput) => {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/${env.endpoints.adminManagement}/auth`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (data: RegisterInput) => {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/${env.endpoints.adminManagement}/register`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getStats: async () => {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/${env.endpoints.cmsManagement}/stats`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiManagementService } from "./services";
|
||||||
|
import { QUERY_KEYS } from "@/config/constants";
|
||||||
|
|
||||||
|
export const useApiTokens = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEYS.API_KEYS],
|
||||||
|
queryFn: apiManagementService.getAllTokens,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateApiKey = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: apiManagementService.createKey,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.API_KEYS] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteApiKey = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: apiManagementService.deleteKey,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.API_KEYS] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTestSecure = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEYS.API_KEYS, "test"],
|
||||||
|
queryFn: apiManagementService.testSecure,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export interface ApiToken {
|
||||||
|
UUID_AC: string;
|
||||||
|
TokenCredential_AC: string;
|
||||||
|
CreatedAt_AC: string;
|
||||||
|
UpdatedAt_AC: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiTokensResponse {
|
||||||
|
total: number;
|
||||||
|
tokens: ApiToken[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { apiClient } from "@/lib/api-client";
|
||||||
|
import { env } from "@/config/env";
|
||||||
|
|
||||||
|
export const apiManagementService = {
|
||||||
|
getAllTokens: async () => {
|
||||||
|
const response = await apiClient.get(`/${env.endpoints.apiManagement}/tokens`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createKey: async () => {
|
||||||
|
const response = await apiClient.post(`/${env.endpoints.apiManagement}/token`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteKey: async (apiKey: string) => {
|
||||||
|
const response = await apiClient.delete(
|
||||||
|
`/${env.endpoints.apiManagement}/token`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"target-x-api-key": apiKey,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
testSecure: async () => {
|
||||||
|
const response = await apiClient.get(`/${env.endpoints.apiManagement}/test/secure`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { campaignsService } from "./services";
|
||||||
|
import { QUERY_KEYS } from "@/config/constants";
|
||||||
|
|
||||||
|
export const useCampaignList = (status?: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEYS.CAMPAIGNS, status],
|
||||||
|
queryFn: () => campaignsService.getAll(status),
|
||||||
|
refetchInterval: 60000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSendCampaign = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: campaignsService.sendSingle,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.CAMPAIGNS] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSetupCampaign = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: campaignsService.setup,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.CAMPAIGNS] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCancelCampaign = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: campaignsService.cancel,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.CAMPAIGNS] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateCampaign = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Parameters<typeof campaignsService.update>[1] }) =>
|
||||||
|
campaignsService.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.CAMPAIGNS] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteCampaign = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: campaignsService.cancel,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.CAMPAIGNS] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCampaignAnalytics = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEYS.CAMPAIGNS, "analytics"],
|
||||||
|
queryFn: () => campaignsService.getAnalytics(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCampaignReport = (id: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEYS.CAMPAIGNS, "report", id],
|
||||||
|
queryFn: () => campaignsService.getReport(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const sendCampaignSchema = z.object({
|
||||||
|
userID: z.string().min(1, "User ID is required"),
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
body: z.string().min(1, "Body is required"),
|
||||||
|
data: z.record(z.any()).optional().default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setupCampaignSchema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
content: z.string().min(1, "Content is required"),
|
||||||
|
date: z.string().min(1, "Date is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateCampaignSchema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required").optional(),
|
||||||
|
content: z.string().min(1, "Content is required").optional(),
|
||||||
|
date: z.string().min(1, "Date is required").optional(),
|
||||||
|
status: z.enum(["pending", "completed", "cancelled", "failed"]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SendCampaignInput = z.infer<typeof sendCampaignSchema>;
|
||||||
|
export type SetupCampaignInput = z.infer<typeof setupCampaignSchema>;
|
||||||
|
export type UpdateCampaignInput = z.infer<typeof updateCampaignSchema>;
|
||||||
|
|
||||||
|
export interface Campaign {
|
||||||
|
UUID_ACP: string;
|
||||||
|
Title_ACP: string;
|
||||||
|
Content_ACP: string;
|
||||||
|
Date_ACP: string;
|
||||||
|
Status_ACP: "pending" | "completed" | "cancelled" | "failed";
|
||||||
|
TargetUsers_ACP: number;
|
||||||
|
SentCount_ACP: number;
|
||||||
|
SuccessCount_ACP: number;
|
||||||
|
FailureCount_ACP: number;
|
||||||
|
DeliveryRate_ACP: number;
|
||||||
|
SentAt_ACP: string | null;
|
||||||
|
CompletedAt_ACP: string | null;
|
||||||
|
ErrorMessage_ACP: string | null;
|
||||||
|
UpdatedAt_ACP: string;
|
||||||
|
CreatedAt_ACP: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignAnalytics {
|
||||||
|
summary: {
|
||||||
|
campaigns: {
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
pending: number;
|
||||||
|
cancelled: number;
|
||||||
|
successRate: string;
|
||||||
|
avgResponseTime: string;
|
||||||
|
upcomingCount: number;
|
||||||
|
};
|
||||||
|
delivery: {
|
||||||
|
totalTargetUsers: number;
|
||||||
|
totalSent: number;
|
||||||
|
totalDelivered: number;
|
||||||
|
totalFailed: number;
|
||||||
|
overallDeliveryRate: string;
|
||||||
|
totalDeliveryRecords: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
charts: {
|
||||||
|
statusDistribution: {
|
||||||
|
labels: string[];
|
||||||
|
data: number[];
|
||||||
|
};
|
||||||
|
deliveryStatusDistribution: {
|
||||||
|
labels: string[];
|
||||||
|
data: number[];
|
||||||
|
};
|
||||||
|
campaignTimeline: Record<string, any>;
|
||||||
|
creationTrend: {
|
||||||
|
labels: string[];
|
||||||
|
data: number[];
|
||||||
|
};
|
||||||
|
statusOverTime: Record<string, Record<string, number>>;
|
||||||
|
deliveryRateTrend: {
|
||||||
|
labels: string[];
|
||||||
|
data: number[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
topPerforming: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
targetUsers: number;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
deliveryRate: number;
|
||||||
|
date: string;
|
||||||
|
}>;
|
||||||
|
upcoming: any[];
|
||||||
|
recentActivity: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignAnalyticsResponse {
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
data: CampaignAnalytics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignReport {
|
||||||
|
campaign: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
status: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
};
|
||||||
|
metrics: {
|
||||||
|
leadTime: string;
|
||||||
|
executionTime: string;
|
||||||
|
isScheduled: boolean;
|
||||||
|
isOverdue: boolean;
|
||||||
|
timeUntilExecution: string;
|
||||||
|
status: string;
|
||||||
|
avgDeliveryTime: string;
|
||||||
|
};
|
||||||
|
delivery: {
|
||||||
|
targetUsers: number;
|
||||||
|
sentCount: number;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
deliveryRate: string;
|
||||||
|
sentAt: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
statusBreakdown: {
|
||||||
|
pending: number;
|
||||||
|
sent: number;
|
||||||
|
delivered: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
errorBreakdown: Record<string, number>;
|
||||||
|
};
|
||||||
|
timeline: {
|
||||||
|
created: string;
|
||||||
|
scheduled: string;
|
||||||
|
sent: string | null;
|
||||||
|
completed: string | null;
|
||||||
|
executed: string;
|
||||||
|
};
|
||||||
|
deliveryRecords: {
|
||||||
|
total: number;
|
||||||
|
records: any[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignReportResponse {
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
data: CampaignReport;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { apiClient } from "@/lib/api-client";
|
||||||
|
import { env } from "@/config/env";
|
||||||
|
import type { SendCampaignInput, SetupCampaignInput, UpdateCampaignInput } from "./schemas";
|
||||||
|
|
||||||
|
export const campaignsService = {
|
||||||
|
getAll: async (status?: string) => {
|
||||||
|
const params = status ? { status } : {};
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/${env.endpoints.campaignManagement}/all`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
sendSingle: async (data: SendCampaignInput) => {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/${env.endpoints.campaignManagement}/send`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
setup: async (data: SetupCampaignInput) => {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/${env.endpoints.campaignManagement}/setup`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: UpdateCampaignInput) => {
|
||||||
|
const response = await apiClient.put(
|
||||||
|
`/${env.endpoints.campaignManagement}/${id}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel: async (id: string) => {
|
||||||
|
const response = await apiClient.delete(
|
||||||
|
`/${env.endpoints.campaignManagement}/${id}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAnalytics: async () => {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/${env.endpoints.campaignManagement}/analytics`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getReport: async (id: string) => {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/${env.endpoints.campaignManagement}/report/${id}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { bucketService } from "./services";
|
||||||
|
import { QUERY_KEYS } from "@/config/constants";
|
||||||
|
|
||||||
|
export const useBucketList = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEYS.CMS_BUCKETS],
|
||||||
|
queryFn: bucketService.getAll,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateBucket = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: bucketService.create,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.CMS_BUCKETS] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateBucket = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: bucketService.update,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.CMS_BUCKETS] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteBucket = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: bucketService.delete,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.CMS_BUCKETS] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const bucketSchema = z.object({
|
||||||
|
bucketName: z.string().min(1, "Bucket name is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bucketUpdateSchema = z.object({
|
||||||
|
oldBucket: z.string().min(1, "Old bucket name is required"),
|
||||||
|
newBucket: z.string().min(1, "New bucket name is required"),
|
||||||
|
policy: z.enum(["public", "private"]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BucketInput = z.infer<typeof bucketSchema>;
|
||||||
|
export type BucketUpdateInput = z.infer<typeof bucketUpdateSchema>;
|
||||||
|
|
||||||
|
export interface Bucket {
|
||||||
|
name: string;
|
||||||
|
policy?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { apiClient } from "@/lib/api-client";
|
||||||
|
import { env } from "@/config/env";
|
||||||
|
import type { BucketInput, BucketUpdateInput } from "./schemas";
|
||||||
|
|
||||||
|
export const bucketService = {
|
||||||
|
getAll: async () => {
|
||||||
|
const response = await apiClient.get(`/${env.endpoints.bucketManagement}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: BucketInput) => {
|
||||||
|
const response = await apiClient.post(`/${env.endpoints.bucketManagement}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (data: BucketUpdateInput) => {
|
||||||
|
const response = await apiClient.put(`/${env.endpoints.bucketManagement}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (bucketName: string) => {
|
||||||
|
const response = await apiClient.delete(`/${env.endpoints.bucketManagement}`, {
|
||||||
|
data: { bucketName },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { contentService } from "./services";
|
||||||
|
import { QUERY_KEYS } from "@/config/constants";
|
||||||
|
import type { ContentInput, ContentUpdateInput, CmsType } from "./schemas";
|
||||||
|
|
||||||
|
export const useContentList = (cmsType: CmsType) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEYS.CMS_CONTENT, cmsType],
|
||||||
|
queryFn: () => contentService.getAll(cmsType),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateContent = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: ContentInput) => contentService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.CMS_CONTENT] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateContent = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: ContentUpdateInput }) =>
|
||||||
|
contentService.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.CMS_CONTENT] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteContent = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => contentService.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.CMS_CONTENT] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const cmsTypeEnum = z.enum(["splash", "promo", "article", "banner", "floatingWidget"]);
|
||||||
|
export const corpTypeEnum = z.enum(["walanja", "simaya", "cifo"]);
|
||||||
|
|
||||||
|
export const contentSchema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
description: z.string().nullable().optional(),
|
||||||
|
image: z.any().optional(),
|
||||||
|
type: cmsTypeEnum,
|
||||||
|
corp: corpTypeEnum,
|
||||||
|
targetUrl: z.string().url("Invalid URL").optional().or(z.literal("")),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const contentUpdateSchema = contentSchema.partial();
|
||||||
|
|
||||||
|
export type ContentInput = z.infer<typeof contentSchema>;
|
||||||
|
export type ContentUpdateInput = z.infer<typeof contentUpdateSchema>;
|
||||||
|
export type CmsType = z.infer<typeof cmsTypeEnum>;
|
||||||
|
export type CorpType = z.infer<typeof corpTypeEnum>;
|
||||||
|
|
||||||
|
export interface Content {
|
||||||
|
UUID_APC: string;
|
||||||
|
Title_APC: string;
|
||||||
|
Content_APC?: string;
|
||||||
|
Type_APC: CmsType;
|
||||||
|
CorpType_APC: CorpType;
|
||||||
|
Url_APC?: string;
|
||||||
|
Filename_APC?: string;
|
||||||
|
TargetUrl_APC?: string;
|
||||||
|
UpdatedAt_APC: string;
|
||||||
|
CreatedAt_APC: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { apiClient } from "@/lib/api-client";
|
||||||
|
import { env } from "@/config/env";
|
||||||
|
import type { ContentInput, ContentUpdateInput, CmsType } from "./schemas";
|
||||||
|
|
||||||
|
export const contentService = {
|
||||||
|
getAll: async (cmsType: CmsType) => {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/${env.endpoints.cmsManagement}/get/${cmsType}/all`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: ContentInput) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
formData.append(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/${env.endpoints.cmsManagement}/create`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: ContentUpdateInput) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
formData.append(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await apiClient.put(
|
||||||
|
`/${env.endpoints.cmsManagement}/update/${id}`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string) => {
|
||||||
|
const response = await apiClient.delete(
|
||||||
|
`/${env.endpoints.cmsManagement}/delete/${id}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { openaiService } from "./services";
|
||||||
|
|
||||||
|
export const useGenerateResponse = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: openaiService.generateResponse,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const openaiPromptSchema = z.object({
|
||||||
|
prompt: z.any(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OpenAIPromptInput = z.infer<typeof openaiPromptSchema>;
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { apiClient } from "@/lib/api-client";
|
||||||
|
import type { OpenAIPromptInput } from "./schemas";
|
||||||
|
|
||||||
|
export const openaiService = {
|
||||||
|
generateResponse: async (data: OpenAIPromptInput) => {
|
||||||
|
const response = await apiClient.post("/openai-management/test", data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { usersService } from "./services";
|
||||||
|
import { QUERY_KEYS } from "@/config/constants";
|
||||||
|
|
||||||
|
export const useUsers = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEYS.USERS],
|
||||||
|
queryFn: usersService.getAll,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSetupUserToken = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: usersService.setupToken,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const userTokenSchema = z.object({
|
||||||
|
userID: z.string().min(1, "User ID is required"),
|
||||||
|
token: z.string().min(1, "Token is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UserTokenInput = z.infer<typeof userTokenSchema>;
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
UUID_UT: string;
|
||||||
|
UserID_UT: string;
|
||||||
|
Token_UT: string;
|
||||||
|
UpdatedAt_UT: string;
|
||||||
|
CreatedAt_UT: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsersResponse {
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
data: User[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { apiClient } from "@/lib/api-client";
|
||||||
|
import { env } from "@/config/env";
|
||||||
|
import type { UserTokenInput, UsersResponse } from "./schemas";
|
||||||
|
|
||||||
|
export const usersService = {
|
||||||
|
getAll: async (): Promise<UsersResponse> => {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/${env.endpoints.userManagement}/get-all`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
setupToken: async (data: UserTokenInput) => {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/${env.endpoints.userManagement}/setup-token`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,52 @@
|
||||||
|
{
|
||||||
|
"name": "admin-csa",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.9.1",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
|
"@radix-ui/react-checkbox": "^1.1.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.3",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.3",
|
||||||
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
|
"@radix-ui/react-popover": "^1.1.3",
|
||||||
|
"@radix-ui/react-select": "^2.1.3",
|
||||||
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
|
"@radix-ui/react-toast": "^1.2.3",
|
||||||
|
"@tanstack/react-query": "^5.62.11",
|
||||||
|
"@tanstack/react-table": "^8.20.6",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"next": "15.1.3",
|
||||||
|
"next-themes": "^0.4.4",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hook-form": "^7.54.2",
|
||||||
|
"recharts": "^3.5.0",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "15.1.3",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
email: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
login: (token: string, user: User) => void;
|
||||||
|
logout: () => void;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedToken = localStorage.getItem("token");
|
||||||
|
const storedUser = localStorage.getItem("user");
|
||||||
|
|
||||||
|
if (storedToken && storedUser) {
|
||||||
|
setToken(storedToken);
|
||||||
|
setUser(JSON.parse(storedUser));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = (newToken: string, newUser: User) => {
|
||||||
|
localStorage.setItem("token", newToken);
|
||||||
|
localStorage.setItem("user", JSON.stringify(newUser));
|
||||||
|
document.cookie = `token=${newToken}; path=/; max-age=${60 * 60 * 24 * 7}`; // 7 days
|
||||||
|
|
||||||
|
setToken(newToken);
|
||||||
|
setUser(newUser);
|
||||||
|
router.push("/");
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
localStorage.removeItem("user");
|
||||||
|
document.cookie = "token=; path=/; max-age=0";
|
||||||
|
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
router.push("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
isAuthenticated: !!token,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
|
||||||
|
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextThemesProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||||
|
{children}
|
||||||
|
</NextThemesProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./modules/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: Application
|
||||||
|
metadata:
|
||||||
|
name: csa-dashboard
|
||||||
|
namespace: argocd
|
||||||
|
spec:
|
||||||
|
project: csa-project
|
||||||
|
source:
|
||||||
|
repoURL:
|
||||||
|
targetRevision:
|
||||||
|
path:
|
||||||
|
destination:
|
||||||
|
server: https://kubernetes.default.svc
|
||||||
|
namespace: argocd
|
||||||
|
syncPolicy:
|
||||||
|
automated:
|
||||||
|
prune: true
|
||||||
|
selfHeal: true
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: AppProject
|
||||||
|
metadata:
|
||||||
|
name: csa-project
|
||||||
|
namespace: argocd
|
||||||
|
spec:
|
||||||
|
description: ArgoCD Project Csa Dashboard
|
||||||
|
sourceRepos:
|
||||||
|
- ''
|
||||||
|
destinations:
|
||||||
|
- namespace: argocd
|
||||||
|
server: https://kubernetes.default.svc
|
||||||
|
clusterResourceWhitelist:
|
||||||
|
- group: '*'
|
||||||
|
kind: '*'
|
||||||
|
namespaceResourceWhitelist:
|
||||||
|
- group: '*'
|
||||||
|
kind: '*'
|
||||||
Loading…
Reference in New Issue