Compare commits
No commits in common. "f470c96f5f463b1d6ad1ca92bcdf3531763f455a" and "a62dcf8b346c3e41117e02159858b9b69b84fcdb" have entirely different histories.
f470c96f5f
...
a62dcf8b34
|
|
@ -4,14 +4,16 @@ FROM node:18-alpine AS builder
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package.json dan install dependencies
|
# Copy package.json and package-lock.json
|
||||||
COPY package.json ./
|
COPY package.json package-lock.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
# Copy seluruh project
|
# Copy project files
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build project
|
# Build the project
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Run
|
# Stage 2: Run
|
||||||
|
|
@ -20,13 +22,13 @@ FROM node:18-alpine
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy built files dari stage builder
|
# Copy built files from builder
|
||||||
COPY --from=builder /app ./
|
COPY --from=builder /app ./
|
||||||
|
|
||||||
# Install hanya dependencies produksi
|
# Install only production dependencies
|
||||||
RUN npm install --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# Expose port sesuai dengan package.json (sesuaikan jika berbeda)
|
# Expose the application port (sesuaikan dengan port yang digunakan di package.json)
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Jalankan aplikasi dengan npm run preview
|
# Jalankan aplikasi dengan npm run preview
|
||||||
|
|
|
||||||
|
|
@ -129,24 +129,25 @@ spec:
|
||||||
app.kubernetes.io/name: nam-frontend-dev
|
app.kubernetes.io/name: nam-frontend-dev
|
||||||
spec:
|
spec:
|
||||||
affinity:
|
affinity:
|
||||||
podAntiAffinity:
|
podAntiAffinity:
|
||||||
preferredDuringSchedulingIgnoredDuringExecution:
|
preferredDuringSchedulingIgnoredDuringExecution:
|
||||||
- weight: 100
|
- weight: 100
|
||||||
podAffinityTerm:
|
podAffinityTerm:
|
||||||
labelSelector:
|
labelSelector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app.kubernetes.io/instance: nam-frontend-dev
|
app.kubernetes.io/instance: nam-frontend-dev
|
||||||
app.kubernetes.io/name: nam-frontend-dev
|
app.kubernetes.io/name: nam-frontend-dev
|
||||||
topologyKey: "kubernetes.io/hostname"
|
topologyKey: "kubernetes.io/hostname"
|
||||||
containers:
|
containers:
|
||||||
- name: web
|
- name: web
|
||||||
image: git.winteraccess.id/winter-access/frontend-nam:dev
|
image: git.winteraccess.id/winter-access/frontend-nam:dev
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
envFrom:
|
env:
|
||||||
- configMapRef:
|
- name: DB_PASSWORD
|
||||||
name: nam-frontend-dev-config
|
valueFrom:
|
||||||
- secretRef:
|
secretKeyRef:
|
||||||
name: nam-frontend-dev-secret
|
name: nam-frontend-dev-secret
|
||||||
|
key: DB_PASSWORD
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpu: "250m"
|
cpu: "250m"
|
||||||
|
|
@ -167,12 +168,8 @@ spec:
|
||||||
terminationMessagePath: /dev/termination-log
|
terminationMessagePath: /dev/termination-log
|
||||||
terminationMessagePolicy: File
|
terminationMessagePolicy: File
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- mountPath: /public
|
- mountPath: /app
|
||||||
name: public
|
name: app
|
||||||
- mountPath: /storage
|
|
||||||
name: storage
|
|
||||||
- mountPath: /tmp
|
|
||||||
name: tmp
|
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: init
|
- name: init
|
||||||
image: git.winteraccess.id/winter-access/frontend-nam:dev
|
image: git.winteraccess.id/winter-access/frontend-nam:dev
|
||||||
|
|
@ -200,12 +197,8 @@ spec:
|
||||||
terminationMessagePath: /dev/termination-log
|
terminationMessagePath: /dev/termination-log
|
||||||
terminationMessagePolicy: File
|
terminationMessagePolicy: File
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- mountPath: /public
|
- mountPath: /app
|
||||||
name: public
|
name: app
|
||||||
- mountPath: /storage
|
|
||||||
name: storage
|
|
||||||
- mountPath: /tmp
|
|
||||||
name: tmp
|
|
||||||
imagePullSecrets:
|
imagePullSecrets:
|
||||||
- name: winter-registry
|
- name: winter-registry
|
||||||
dnsPolicy: ClusterFirst
|
dnsPolicy: ClusterFirst
|
||||||
|
|
@ -237,8 +230,6 @@ spec:
|
||||||
- name: storage
|
- name: storage
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: nam-frontend-dev-storage
|
claimName: nam-frontend-dev-storage
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ interface SidebarProps {
|
||||||
handleLogout: () => void;
|
handleLogout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Sidebar: React.FC<SidebarProps> = ({ sidebarOpen, user }) => {
|
const Sidebar: React.FC<SidebarProps> = ({ sidebarOpen, user, handleLogout }) => {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={`
|
className={`
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Outlet, useNavigate } from 'react-router';
|
import { NavLink, Outlet, useNavigate } from 'react-router';
|
||||||
import { useAuthStore } from '../../features/auth/store';
|
import { useAuthStore } from '../../features/auth/store';
|
||||||
import Sidebar from './Dashboard/Sidebar'; // Import the Sidebar component we created earlier
|
import Sidebar from './Dashboard/Sidebar'; // Import the Sidebar component we created earlier
|
||||||
import Header from './Dashboard/Header';
|
import Header from './Dashboard/Header';
|
||||||
|
|
|
||||||
|
|
@ -1,293 +0,0 @@
|
||||||
import { useState, useCallback } from 'react'
|
|
||||||
import { Upload, Download, AlertCircle, CheckCircle2 } from 'lucide-react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
FileUpload,
|
|
||||||
FileUploadDropzone,
|
|
||||||
FileUploadTrigger,
|
|
||||||
FileUploadList,
|
|
||||||
FileUploadItem,
|
|
||||||
FileUploadItemPreview,
|
|
||||||
FileUploadItemMetadata,
|
|
||||||
FileUploadItemDelete,
|
|
||||||
} from '@/components/ui/file-upload'
|
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
||||||
import { Progress } from '@/components/ui/progress'
|
|
||||||
|
|
||||||
import { useDevicesContext } from '../context/devices-context'
|
|
||||||
import { useBulkCreateDevices } from '../hooks/use-devices'
|
|
||||||
import { CSVParser, ParsedDevice, ValidationResult } from '../utils/csv-parser'
|
|
||||||
|
|
||||||
export function DeviceBulkCreateDialog() {
|
|
||||||
const { isBulkCreateDialogOpen, setIsBulkCreateDialogOpen } = useDevicesContext()
|
|
||||||
const bulkCreateMutation = useBulkCreateDevices()
|
|
||||||
const [csvFile, setCsvFile] = useState<File | null>(null)
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false)
|
|
||||||
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null)
|
|
||||||
const [uploadProgress, setUploadProgress] = useState(0)
|
|
||||||
|
|
||||||
const handleFileChange = (files: File[]) => {
|
|
||||||
if (files.length > 0) {
|
|
||||||
const file = files[0]
|
|
||||||
if (file.type === 'text/csv' || file.name.endsWith('.csv')) {
|
|
||||||
setCsvFile(file)
|
|
||||||
setValidationResult(null)
|
|
||||||
} else {
|
|
||||||
toast.error('Please select a CSV file')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onFileValidate = useCallback(
|
|
||||||
(file: File) => {
|
|
||||||
if (!file.type.includes('csv') && !file.name.endsWith('.csv')) {
|
|
||||||
return 'Only CSV files are allowed'
|
|
||||||
}
|
|
||||||
if (file.size > 5 * 1024 * 1024) { // 5MB limit
|
|
||||||
return 'File size must be less than 5MB'
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const parseCSV = async (file: File): Promise<ParsedDevice[]> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = (e) => {
|
|
||||||
try {
|
|
||||||
const csv = e.target?.result as string
|
|
||||||
const devices = CSVParser.parseCSV(csv)
|
|
||||||
resolve(devices)
|
|
||||||
} catch (error) {
|
|
||||||
reject(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reader.onerror = () => reject(new Error('Failed to read file'))
|
|
||||||
reader.readAsText(file)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateDevices = async () => {
|
|
||||||
if (!csvFile) return
|
|
||||||
|
|
||||||
setIsProcessing(true)
|
|
||||||
try {
|
|
||||||
const devices = await parseCSV(csvFile)
|
|
||||||
const validationResult = CSVParser.validateDevices(devices)
|
|
||||||
|
|
||||||
setValidationResult(validationResult)
|
|
||||||
|
|
||||||
if (validationResult.invalid.length > 0) {
|
|
||||||
toast.warning(`${validationResult.invalid.length} rows have validation errors`)
|
|
||||||
} else {
|
|
||||||
toast.success('All rows are valid!')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to parse CSV file')
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkCreate = async () => {
|
|
||||||
if (!validationResult?.valid.length) return
|
|
||||||
|
|
||||||
setIsProcessing(true)
|
|
||||||
setUploadProgress(0)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const devices = validationResult.valid.map(device => ({
|
|
||||||
device_code: device.device_code,
|
|
||||||
device_type: device.device_type,
|
|
||||||
longitude: device.longitude,
|
|
||||||
latitude: device.latitude,
|
|
||||||
port_amount: device.port_amount,
|
|
||||||
status: device.status,
|
|
||||||
province: device.province,
|
|
||||||
city: device.city,
|
|
||||||
district: device.district,
|
|
||||||
tower_id: device.tower_id,
|
|
||||||
olt: device.olt
|
|
||||||
}))
|
|
||||||
|
|
||||||
await bulkCreateMutation.mutateAsync(devices)
|
|
||||||
setUploadProgress(100)
|
|
||||||
handleClose()
|
|
||||||
} catch (_error) {
|
|
||||||
// Error is handled by the mutation
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false)
|
|
||||||
setUploadProgress(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setCsvFile(null)
|
|
||||||
setValidationResult(null)
|
|
||||||
setUploadProgress(0)
|
|
||||||
setIsProcessing(false)
|
|
||||||
setIsBulkCreateDialogOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloadTemplate = () => {
|
|
||||||
const csvContent = CSVParser.generateTemplate()
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv' })
|
|
||||||
const url = window.URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = 'device-template.csv'
|
|
||||||
a.click()
|
|
||||||
window.URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isBulkCreateDialogOpen} onOpenChange={setIsBulkCreateDialogOpen}>
|
|
||||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Bulk Create Devices</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Upload a CSV file to create multiple devices at once.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Template Download */}
|
|
||||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">Download Template</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Download the CSV template to see the required format
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={downloadTemplate}>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Download Template
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* File Upload */}
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium mb-2">Upload CSV File</h4>
|
|
||||||
<FileUpload
|
|
||||||
value={csvFile ? [csvFile] : []}
|
|
||||||
onValueChange={handleFileChange}
|
|
||||||
accept=".csv"
|
|
||||||
maxFiles={1}
|
|
||||||
onFileValidate={onFileValidate}
|
|
||||||
>
|
|
||||||
<FileUploadDropzone>
|
|
||||||
<div className="flex flex-col items-center justify-center py-8">
|
|
||||||
<Upload className="w-8 h-8 mb-2 text-muted-foreground" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Drop your CSV file here or click to browse
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</FileUploadDropzone>
|
|
||||||
<FileUploadTrigger asChild>
|
|
||||||
<Button variant="outline">
|
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
|
||||||
Select CSV File
|
|
||||||
</Button>
|
|
||||||
</FileUploadTrigger>
|
|
||||||
<FileUploadList>
|
|
||||||
{csvFile && (
|
|
||||||
<FileUploadItem value={csvFile}>
|
|
||||||
<FileUploadItemPreview />
|
|
||||||
<FileUploadItemMetadata />
|
|
||||||
<FileUploadItemDelete />
|
|
||||||
</FileUploadItem>
|
|
||||||
)}
|
|
||||||
</FileUploadList>
|
|
||||||
</FileUpload>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Validation Button */}
|
|
||||||
{csvFile && !validationResult && (
|
|
||||||
<Button
|
|
||||||
onClick={validateDevices}
|
|
||||||
disabled={isProcessing}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isProcessing ? 'Validating...' : 'Validate CSV Data'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Validation Results */}
|
|
||||||
{validationResult && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div className="text-center p-4 bg-green-50 rounded-lg">
|
|
||||||
<CheckCircle2 className="w-8 h-8 mx-auto mb-2 text-green-600" />
|
|
||||||
<p className="font-medium text-green-900">{validationResult.valid.length}</p>
|
|
||||||
<p className="text-sm text-green-700">Valid Rows</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-4 bg-red-50 rounded-lg">
|
|
||||||
<AlertCircle className="w-8 h-8 mx-auto mb-2 text-red-600" />
|
|
||||||
<p className="font-medium text-red-900">{validationResult.invalid.length}</p>
|
|
||||||
<p className="text-sm text-red-700">Invalid Rows</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
|
||||||
<p className="font-medium text-blue-900">{validationResult.totalRows}</p>
|
|
||||||
<p className="text-sm text-blue-700">Total Rows</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Invalid Rows Details */}
|
|
||||||
{validationResult.invalid.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium mb-2 text-red-900">Validation Errors</h4>
|
|
||||||
<div className="max-h-40 overflow-y-auto space-y-2">
|
|
||||||
{validationResult.invalid.map((device, index) => (
|
|
||||||
<Alert key={index} variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
<strong>Row {device.row}:</strong> {device.errors.join(', ')}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Upload Progress */}
|
|
||||||
{isProcessing && uploadProgress > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span>Creating devices...</span>
|
|
||||||
<span>{Math.round(uploadProgress)}%</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={uploadProgress} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={handleClose} disabled={isProcessing}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
{validationResult && validationResult.valid.length > 0 && (
|
|
||||||
<Button
|
|
||||||
onClick={handleBulkCreate}
|
|
||||||
disabled={isProcessing || validationResult.invalid.length > 0}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isProcessing ? 'Creating...' : `Create ${validationResult.valid.length} Devices`}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { RoleBasedRender } from '@/components/role-based/role-based-render'
|
|
||||||
import { DeviceDeleteDialog } from './device-delete-dialog'
|
|
||||||
import { DeviceEditDialog } from './device-edit-dialog'
|
|
||||||
import { PortSettingsModal } from './port-settings-modal'
|
|
||||||
import { DeviceBulkCreateDialog } from './device-bulk-create-dialog'
|
|
||||||
|
|
||||||
export function DevicesDialogs() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DeviceEditDialog />
|
|
||||||
<DeviceDeleteDialog />
|
|
||||||
<RoleBasedRender allowedRoles={['Super Admin', 'Admin']}>
|
|
||||||
<PortSettingsModal />
|
|
||||||
<DeviceBulkCreateDialog />
|
|
||||||
</RoleBasedRender>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { IconPlus, IconUpload } from '@tabler/icons-react'
|
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
|
||||||
import { useDevicesContext } from '../context/devices-context'
|
|
||||||
|
|
||||||
export function DevicesPrimaryButtons() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const { setIsBulkCreateDialogOpen } = useDevicesContext()
|
|
||||||
|
|
||||||
const handleCreateDevice = () => {
|
|
||||||
navigate({ to: '/devices/create' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkCreate = () => {
|
|
||||||
setIsBulkCreateDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='flex gap-2'>
|
|
||||||
<Button size='sm' onClick={handleCreateDevice}>
|
|
||||||
<IconPlus className='h-4 w-4' /> Tambah perangkat
|
|
||||||
</Button>
|
|
||||||
<Button size='sm' variant='outline' onClick={handleBulkCreate}>
|
|
||||||
<IconUpload className='h-4 w-4' /> Bulk Import
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
import { useDevicesContext } from '../context/devices-context'
|
|
||||||
import { useProvinces, useRegencies, useDistricts } from '@/services/indonesia-regions'
|
|
||||||
import { InputSelect, InputSelectTrigger } from '@/components/ui/input-select'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
|
|
||||||
export function DevicesRegionFilter() {
|
|
||||||
const {
|
|
||||||
filters,
|
|
||||||
selectedProvince,
|
|
||||||
selectedCity,
|
|
||||||
selectedDistrict,
|
|
||||||
handleProvinceChange,
|
|
||||||
handleCityChange,
|
|
||||||
handleDistrictChange,
|
|
||||||
} = useDevicesContext()
|
|
||||||
|
|
||||||
const { data: provinces } = useProvinces()
|
|
||||||
const { data: regencies, isLoading: isLoadingRegencies } = useRegencies(
|
|
||||||
selectedProvince
|
|
||||||
)
|
|
||||||
const { data: districts, isLoading: isLoadingDistricts } = useDistricts(
|
|
||||||
selectedCity
|
|
||||||
)
|
|
||||||
|
|
||||||
const provinceOptions = provinces?.map((province) => ({
|
|
||||||
value: province.id,
|
|
||||||
label: province.name,
|
|
||||||
})) ?? []
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const cityOptions = regencies?.map((city) => ({
|
|
||||||
value: city.id,
|
|
||||||
label: city.name,
|
|
||||||
})) ?? []
|
|
||||||
|
|
||||||
const districtOptions = districts?.map((district) => ({
|
|
||||||
value: district.id,
|
|
||||||
label: district.name,
|
|
||||||
})) ?? []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
||||||
{/* Province Filter */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Provinsi</Label>
|
|
||||||
<InputSelect
|
|
||||||
options={[
|
|
||||||
{ value: '', label: 'Semua Provinsi' },
|
|
||||||
...provinceOptions,
|
|
||||||
]}
|
|
||||||
value={selectedProvince || ''}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const selectedOption = provinceOptions.find(opt => opt.value === value);
|
|
||||||
const provinceName = selectedOption?.label || value;
|
|
||||||
const provinceId = selectedOption?.value || value;
|
|
||||||
// Send both ID (for API) and name (for filtering)
|
|
||||||
handleProvinceChange(provinceId, provinceName);
|
|
||||||
}}
|
|
||||||
placeholder="Pilih provinsi"
|
|
||||||
>
|
|
||||||
{(provided) => (
|
|
||||||
<InputSelectTrigger
|
|
||||||
{...provided}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</InputSelect>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* City Filter */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Kota/Kabupaten</Label>
|
|
||||||
<InputSelect
|
|
||||||
options={[
|
|
||||||
{ value: '', label: 'Semua Kota/Kabupaten' },
|
|
||||||
...cityOptions,
|
|
||||||
]}
|
|
||||||
disabled={!selectedProvince || isLoadingRegencies}
|
|
||||||
value={selectedCity || ''}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const selectedOption = cityOptions.find(opt => opt.value === value);
|
|
||||||
const cityName = selectedOption?.label || value;
|
|
||||||
const cityId = selectedOption?.value || value;
|
|
||||||
handleCityChange(cityId, cityName);
|
|
||||||
}}
|
|
||||||
placeholder={
|
|
||||||
!filters.province
|
|
||||||
? 'Pilih provinsi dulu'
|
|
||||||
: isLoadingRegencies
|
|
||||||
? 'Loading...'
|
|
||||||
: 'Pilih kota/kabupaten'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(provided) => (
|
|
||||||
<InputSelectTrigger
|
|
||||||
{...provided}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</InputSelect>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* District Filter */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Kecamatan</Label>
|
|
||||||
<InputSelect
|
|
||||||
options={[
|
|
||||||
{ value: '', label: 'Semua Kecamatan' },
|
|
||||||
...districtOptions,
|
|
||||||
]}
|
|
||||||
disabled={!selectedCity || isLoadingDistricts}
|
|
||||||
value={selectedDistrict || ''}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const selectedOption = districtOptions.find(opt => opt.value === value);
|
|
||||||
const districtName = selectedOption?.label || value;
|
|
||||||
const districtId = selectedOption?.value || value;
|
|
||||||
handleDistrictChange(districtId, districtName);
|
|
||||||
}}
|
|
||||||
placeholder={
|
|
||||||
!filters.city
|
|
||||||
? 'Pilih kota/kabupaten dulu'
|
|
||||||
: isLoadingDistricts
|
|
||||||
? 'Loading...'
|
|
||||||
: 'Pilih kecamatan'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(provided) => (
|
|
||||||
<InputSelectTrigger
|
|
||||||
{...provided}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</InputSelect>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,265 +0,0 @@
|
||||||
import React, { createContext, useContext, useState } from 'react'
|
|
||||||
import { DeviceComplete, DeviceFilters, DeviceSorting } from '../types/device'
|
|
||||||
|
|
||||||
interface DevicesContextType {
|
|
||||||
// UI State
|
|
||||||
selectedDevices: string[]
|
|
||||||
setSelectedDevices: (devices: string[]) => void
|
|
||||||
|
|
||||||
// Dialog states
|
|
||||||
isEditDialogOpen: boolean
|
|
||||||
setIsEditDialogOpen: (open: boolean) => void
|
|
||||||
|
|
||||||
isDeleteDialogOpen: boolean
|
|
||||||
setIsDeleteDialogOpen: (open: boolean) => void
|
|
||||||
|
|
||||||
isPortSettingsModalOpen: boolean
|
|
||||||
setIsPortSettingsModalOpen: (open: boolean) => void
|
|
||||||
|
|
||||||
isBulkCreateDialogOpen: boolean
|
|
||||||
setIsBulkCreateDialogOpen: (open: boolean) => void
|
|
||||||
|
|
||||||
// Current device for edit/delete
|
|
||||||
currentDevice: DeviceComplete | null
|
|
||||||
setCurrentDevice: (device: DeviceComplete | null) => void
|
|
||||||
|
|
||||||
// Frontend filtering and sorting
|
|
||||||
filters: DeviceFilters
|
|
||||||
setFilters: (filters: DeviceFilters) => void
|
|
||||||
|
|
||||||
sorting: DeviceSorting | null
|
|
||||||
setSorting: (sorting: DeviceSorting | null) => void
|
|
||||||
|
|
||||||
// Pagination (frontend)
|
|
||||||
currentPage: number
|
|
||||||
setCurrentPage: (page: number) => void
|
|
||||||
|
|
||||||
pageSize: number
|
|
||||||
setPageSize: (size: number) => void
|
|
||||||
|
|
||||||
// Region filtering state
|
|
||||||
selectedProvince: string
|
|
||||||
setSelectedProvince: (province: string) => void
|
|
||||||
selectedCity: string
|
|
||||||
setSelectedCity: (city: string) => void
|
|
||||||
selectedDistrict: string
|
|
||||||
setSelectedDistrict: (district: string) => void
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
openEditDialog: (device: DeviceComplete) => void
|
|
||||||
openDeleteDialog: (device: DeviceComplete) => void
|
|
||||||
closeAllDialogs: () => void
|
|
||||||
|
|
||||||
resetFilters: () => void
|
|
||||||
|
|
||||||
// Bulk operations
|
|
||||||
isBulkMode: boolean
|
|
||||||
setIsBulkMode: (mode: boolean) => void
|
|
||||||
toggleDeviceSelection: (deviceId: string) => void
|
|
||||||
selectAllDevices: (deviceIds: string[]) => void
|
|
||||||
clearSelection: () => void
|
|
||||||
bulkDeleteSelected: () => void
|
|
||||||
|
|
||||||
// Settings Port
|
|
||||||
portSettingsDevice: DeviceComplete | null
|
|
||||||
isPortSettingsOpen: boolean
|
|
||||||
setIsPortSettingsOpen: (open: boolean) => void
|
|
||||||
openPortSettingsDialog: (device: DeviceComplete) => void
|
|
||||||
|
|
||||||
// Region filter handlers
|
|
||||||
handleProvinceChange: (provinceId: string, provinceName?: string) => void
|
|
||||||
handleCityChange: (cityId: string, cityName?: string) => void
|
|
||||||
handleDistrictChange: (districtId: string, districtName?: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const DevicesContext = createContext<DevicesContextType | undefined>(undefined)
|
|
||||||
|
|
||||||
interface DevicesProviderProps {
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialFilters: DeviceFilters = {
|
|
||||||
search: '',
|
|
||||||
status: '',
|
|
||||||
device_type: '',
|
|
||||||
province: '',
|
|
||||||
city: '',
|
|
||||||
district: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DevicesProvider({ children }: DevicesProviderProps) {
|
|
||||||
// UI State
|
|
||||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
|
|
||||||
|
|
||||||
// Dialog states
|
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
|
||||||
const [isBulkCreateDialogOpen, setIsBulkCreateDialogOpen] = useState(false)
|
|
||||||
|
|
||||||
// Current device
|
|
||||||
const [currentDevice, setCurrentDevice] = useState<DeviceComplete | null>(null)
|
|
||||||
|
|
||||||
// Frontend filtering and sorting
|
|
||||||
const [filters, setFilters] = useState<DeviceFilters>(initialFilters)
|
|
||||||
const [sorting, setSorting] = useState<DeviceSorting | null>(null)
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
|
||||||
const [pageSize, setPageSize] = useState(10)
|
|
||||||
|
|
||||||
// Region filtering state
|
|
||||||
const [selectedProvince, setSelectedProvince] = useState('')
|
|
||||||
const [selectedCity, setSelectedCity] = useState('')
|
|
||||||
const [selectedDistrict, setSelectedDistrict] = useState('')
|
|
||||||
|
|
||||||
// Bulk operations state
|
|
||||||
const [isBulkMode, setIsBulkMode] = useState(false)
|
|
||||||
|
|
||||||
// Settings port
|
|
||||||
const [portSettingsDevice, setPortSettingsDevice] = useState<DeviceComplete | null>(null)
|
|
||||||
const [isPortSettingsOpen, setIsPortSettingsOpen] = useState(false)
|
|
||||||
const [isPortSettingsModalOpen, setIsPortSettingsModalOpen] = useState(false)
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
const openEditDialog = (device: DeviceComplete) => {
|
|
||||||
setCurrentDevice(device)
|
|
||||||
setIsEditDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openDeleteDialog = (device: DeviceComplete) => {
|
|
||||||
setCurrentDevice(device)
|
|
||||||
setIsDeleteDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeAllDialogs = () => {
|
|
||||||
setIsEditDialogOpen(false)
|
|
||||||
setIsDeleteDialogOpen(false)
|
|
||||||
setCurrentDevice(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetFilters = () => {
|
|
||||||
setFilters(initialFilters)
|
|
||||||
setSorting(null)
|
|
||||||
setCurrentPage(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openPortSettingsDialog = (device: DeviceComplete) => {
|
|
||||||
setPortSettingsDevice(device)
|
|
||||||
setIsPortSettingsOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Region filter handlers
|
|
||||||
const handleProvinceChange = (provinceId: string, provinceName?: string) => {
|
|
||||||
setSelectedProvince(provinceId) // ID for API calls
|
|
||||||
setSelectedCity('')
|
|
||||||
setSelectedDistrict('')
|
|
||||||
setFilters(prev => ({
|
|
||||||
...prev,
|
|
||||||
province: provinceName || provinceId, // Name for filtering
|
|
||||||
city: '',
|
|
||||||
district: ''
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCityChange = (cityId: string, cityName?: string) => {
|
|
||||||
setSelectedCity(cityId) // ID for API calls
|
|
||||||
setSelectedDistrict('')
|
|
||||||
setFilters(prev => ({
|
|
||||||
...prev,
|
|
||||||
city: cityName || cityId, // Name for filtering
|
|
||||||
district: ''
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDistrictChange = (districtId: string, districtName?: string) => {
|
|
||||||
setSelectedDistrict(districtId) // ID for API calls
|
|
||||||
setFilters(prev => ({
|
|
||||||
...prev,
|
|
||||||
district: districtName || districtId // Name for filtering
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bulk operations functions
|
|
||||||
const toggleDeviceSelection = (deviceId: string) => {
|
|
||||||
setSelectedDevices(prev =>
|
|
||||||
prev.includes(deviceId)
|
|
||||||
? prev.filter(id => id !== deviceId)
|
|
||||||
: [...prev, deviceId]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectAllDevices = (deviceIds: string[]) => {
|
|
||||||
setSelectedDevices(deviceIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearSelection = () => {
|
|
||||||
setSelectedDevices([])
|
|
||||||
setIsBulkMode(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const bulkDeleteSelected = () => {
|
|
||||||
// This will be handled by the bulk delete hook
|
|
||||||
// The actual deletion logic is in the component that calls this
|
|
||||||
console.log('Bulk delete selected devices:', selectedDevices)
|
|
||||||
}
|
|
||||||
|
|
||||||
const value: DevicesContextType = {
|
|
||||||
selectedDevices,
|
|
||||||
setSelectedDevices,
|
|
||||||
isEditDialogOpen,
|
|
||||||
setIsEditDialogOpen,
|
|
||||||
isDeleteDialogOpen,
|
|
||||||
setIsDeleteDialogOpen,
|
|
||||||
isPortSettingsModalOpen,
|
|
||||||
setIsPortSettingsModalOpen,
|
|
||||||
isBulkCreateDialogOpen,
|
|
||||||
setIsBulkCreateDialogOpen,
|
|
||||||
currentDevice,
|
|
||||||
setCurrentDevice,
|
|
||||||
filters,
|
|
||||||
setFilters,
|
|
||||||
sorting,
|
|
||||||
setSorting,
|
|
||||||
currentPage,
|
|
||||||
setCurrentPage,
|
|
||||||
pageSize,
|
|
||||||
setPageSize,
|
|
||||||
selectedProvince,
|
|
||||||
setSelectedProvince,
|
|
||||||
selectedCity,
|
|
||||||
setSelectedCity,
|
|
||||||
selectedDistrict,
|
|
||||||
setSelectedDistrict,
|
|
||||||
openEditDialog,
|
|
||||||
openDeleteDialog,
|
|
||||||
closeAllDialogs,
|
|
||||||
resetFilters,
|
|
||||||
portSettingsDevice,
|
|
||||||
isPortSettingsOpen,
|
|
||||||
setIsPortSettingsOpen,
|
|
||||||
openPortSettingsDialog,
|
|
||||||
handleProvinceChange,
|
|
||||||
handleCityChange,
|
|
||||||
handleDistrictChange,
|
|
||||||
isBulkMode,
|
|
||||||
setIsBulkMode,
|
|
||||||
toggleDeviceSelection,
|
|
||||||
selectAllDevices,
|
|
||||||
clearSelection,
|
|
||||||
bulkDeleteSelected
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DevicesContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</DevicesContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useDevicesContext = () => {
|
|
||||||
const context = useContext(DevicesContext)
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useDevicesContext must be used within a DevicesProvider')
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { devicesService } from '../services/devices-service'
|
|
||||||
import { DevicePortsUpdateRequest } from '../types/device'
|
|
||||||
|
|
||||||
export const useDevices = ({
|
|
||||||
type = 'all',
|
|
||||||
}: { type?: 'otb' | 'closure' | 'odp' | 'all' } = {}) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['devices'],
|
|
||||||
queryFn: devicesService.getDevices,
|
|
||||||
select: (data) => {
|
|
||||||
if (type === 'otb') {
|
|
||||||
return data.data.filter(d => d.device_type.toLowerCase() === 'otb') || []
|
|
||||||
}
|
|
||||||
if (type === 'closure') {
|
|
||||||
return data.data.filter(d => d.device_type.toLowerCase() === 'closure') || []
|
|
||||||
}
|
|
||||||
if (type === 'odp') {
|
|
||||||
return data.data.filter(d => d.device_type.toLowerCase() === 'odp') || []
|
|
||||||
}
|
|
||||||
return data.data || []
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useDevice = (id: string) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['device', id],
|
|
||||||
queryFn: () => devicesService.getDevice(id),
|
|
||||||
select: (data) => data.data,
|
|
||||||
enabled: !!id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useDeviceWithPorts = (id: string) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['device-with-ports', id],
|
|
||||||
queryFn: () => devicesService.getDeviceWithPorts(id),
|
|
||||||
select: (data) => data.data,
|
|
||||||
enabled: !!id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCreateDevice = () => {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: FormData) => devicesService.createDevice(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['devices'] })
|
|
||||||
toast.success('Device created successfully')
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.response?.data?.status?.description || 'Failed to create device')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useUpdateDevice = () => {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ id, formData }: { id: string; formData: FormData }) => devicesService.updateDevice(id, formData),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['devices'] })
|
|
||||||
toast.success('Device updated successfully')
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.response?.data?.status?.description || 'Failed to update device')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useDeleteDeviceImage = () => {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ deviceId, imageName }: { deviceId: string; imageName: string }) =>
|
|
||||||
devicesService.deleteDeviceImage(deviceId, imageName),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['devices'] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['device', variables.deviceId] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['device-with-ports', variables.deviceId] })
|
|
||||||
toast.success('Image deleted successfully')
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.response?.data?.status?.description || 'Failed to delete image')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updated hook to use the new assign-customer endpoint
|
|
||||||
export const useAssignCustomersToPorts = () => {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: DevicePortsUpdateRequest) => devicesService.assignCustomersToPorts(data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['devices'] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['device-with-ports', variables.device_id] })
|
|
||||||
toast.success('Customer assignments updated successfully')
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.response?.data?.status?.description || 'Failed to update customer assignments')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the old hook name for backward compatibility
|
|
||||||
export const useUpdateDevicePorts = useAssignCustomersToPorts
|
|
||||||
|
|
||||||
export const useDeleteDevice = () => {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) => devicesService.deleteDevice(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['devices'] })
|
|
||||||
toast.success('Device deleted successfully')
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.response?.data?.status?.description || 'Failed to delete device')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useBulkDeleteDevices = () => {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (ids: string[]) => devicesService.bulkDeleteDevices(ids),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['devices'] })
|
|
||||||
toast.success('Devices deleted successfully')
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.response?.data?.status?.description || 'Failed to delete devices')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useBulkCreateDevices = () => {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (devices: any[]) => devicesService.bulkCreateDevices(devices),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['devices'] })
|
|
||||||
const successCount = data.data?.success_count || 0
|
|
||||||
const failureCount = data.data?.failure_count || 0
|
|
||||||
|
|
||||||
if (failureCount > 0) {
|
|
||||||
toast.warning(`${successCount} devices created successfully, ${failureCount} failed`)
|
|
||||||
} else {
|
|
||||||
toast.success(`${successCount} devices created successfully`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.response?.data?.status?.description || 'Failed to create devices')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
import { useState, useMemo } from 'react'
|
|
||||||
import { Header } from '@/components/layout/header'
|
|
||||||
import { Main } from '@/components/layout/main'
|
|
||||||
import { ProfileDropdown } from '@/components/profile-dropdown'
|
|
||||||
import { Search } from '@/components/search'
|
|
||||||
import { ThemeSwitch } from '@/components/theme-switch'
|
|
||||||
import { columns } from './components/columns'
|
|
||||||
import { DataTable } from './components/data-table'
|
|
||||||
import { DevicesDialogs } from './components/devices-dialogs'
|
|
||||||
import { DevicesPrimaryButtons } from './components/devices-primary-buttons'
|
|
||||||
import { ViewSwitcher } from './components/view-switcher'
|
|
||||||
import { DevicesCards } from './components/devices-cards'
|
|
||||||
import { DevicesRegionFilter } from './components/devices-region-filter'
|
|
||||||
import DevicesProvider, { useDevicesContext } from './context/devices-context'
|
|
||||||
import { useDevices } from './hooks/use-devices'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { handleServerError } from '@/utils/handle-server-error'
|
|
||||||
|
|
||||||
|
|
||||||
function DevicesContent() {
|
|
||||||
const { data: devices, isLoading, error } = useDevices()
|
|
||||||
const { filters } = useDevicesContext()
|
|
||||||
const [view, setView] = useState<'table' | 'cards'>('table')
|
|
||||||
|
|
||||||
// Filter devices based on region filters
|
|
||||||
const filteredDevices = useMemo(() => {
|
|
||||||
if (!devices) return []
|
|
||||||
|
|
||||||
return devices.filter((device) => {
|
|
||||||
const matchesProvince = !filters.province || device.province === filters.province
|
|
||||||
const matchesCity = !filters.city || device.city === filters.city
|
|
||||||
const matchesDistrict = !filters.district || device.district === filters.district
|
|
||||||
|
|
||||||
return matchesProvince && matchesCity && matchesDistrict
|
|
||||||
})
|
|
||||||
}, [devices, filters.province, filters.city, filters.district])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (error) {
|
|
||||||
handleServerError(error)
|
|
||||||
}
|
|
||||||
}, [error])
|
|
||||||
|
|
||||||
const renderSkeletons = () => {
|
|
||||||
if (view === 'table') {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<Skeleton className="h-8 w-32" />
|
|
||||||
<Skeleton className="h-8 w-64" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-48 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header fixed>
|
|
||||||
<Search />
|
|
||||||
<div className='ml-auto flex items-center space-x-4'>
|
|
||||||
<ThemeSwitch />
|
|
||||||
<ProfileDropdown />
|
|
||||||
</div>
|
|
||||||
</Header>
|
|
||||||
|
|
||||||
<Main>
|
|
||||||
<div className='mb-2 flex flex-wrap items-center justify-between space-y-2 gap-x-4'>
|
|
||||||
<div>
|
|
||||||
<h2 className='text-2xl font-bold tracking-tight'>Perangkat</h2>
|
|
||||||
<p className='text-muted-foreground'>
|
|
||||||
Berikut adalah daftar perangkat Anda!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<ViewSwitcher view={view} onViewChange={setView} />
|
|
||||||
<DevicesPrimaryButtons />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Region Filter */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<DevicesRegionFilter />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='-mx-4 flex-1 overflow-auto px-4 py-1 lg:flex-row lg:space-y-0 lg:space-x-12'>
|
|
||||||
{isLoading ? (
|
|
||||||
renderSkeletons()
|
|
||||||
) : view === 'table' ? (
|
|
||||||
<DataTable
|
|
||||||
data={filteredDevices || []}
|
|
||||||
columns={columns}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DevicesCards devices={filteredDevices || []} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Main>
|
|
||||||
|
|
||||||
<DevicesDialogs />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Devices() {
|
|
||||||
return (
|
|
||||||
<DevicesProvider>
|
|
||||||
<DevicesContent />
|
|
||||||
</DevicesProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
import { apiClient } from '@/lib/api'
|
|
||||||
import { ApiResponse } from '@/lib/api/types'
|
|
||||||
import {
|
|
||||||
DeviceResponse,
|
|
||||||
DevicePortsUpdateRequest,
|
|
||||||
DeviceComplete,
|
|
||||||
DevicesWithoutConnectionsResponse,
|
|
||||||
DevicesWithoutTowersResponse
|
|
||||||
} from '../types/device'
|
|
||||||
|
|
||||||
export const devicesService = {
|
|
||||||
// Get all devices (no query params - get everything)
|
|
||||||
getDevices: async (): Promise<ApiResponse<DeviceComplete[]>> => {
|
|
||||||
return apiClient.get<DeviceComplete[]>('/device-details')
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get single device by ID
|
|
||||||
getDevice: async (id: string): Promise<ApiResponse<DeviceComplete>> => {
|
|
||||||
return apiClient.get<DeviceComplete>(`/device-details/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Create new device
|
|
||||||
createDevice: async (data: FormData): Promise<ApiResponse<DeviceResponse>> => {
|
|
||||||
return apiClient.postFormData<DeviceResponse>('/devices', data)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Update device
|
|
||||||
updateDevice: async (id: string, data: FormData): Promise<ApiResponse<DeviceResponse>> => {
|
|
||||||
return apiClient.putFormData<DeviceResponse>(`/device-details/${id}`, data)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Delete device
|
|
||||||
deleteDevice: async (id: string): Promise<ApiResponse<null>> => {
|
|
||||||
return apiClient.delete<null>(`/devices/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Delete device image - NEW
|
|
||||||
deleteDeviceImage: async (deviceId: string, imageName: string): Promise<ApiResponse<null>> => {
|
|
||||||
return apiClient.delete<null>(`/device-details/${deviceId}/images/${imageName}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Bulk delete devices
|
|
||||||
bulkDeleteDevices: async (ids: string[]): Promise<ApiResponse<null>> => {
|
|
||||||
return apiClient.post<null>('/devices/bulk-delete', { ids })
|
|
||||||
},
|
|
||||||
|
|
||||||
// Bulk create devices
|
|
||||||
bulkCreateDevices: async (devices: any[]): Promise<ApiResponse<{ success_count: number; failure_count: number; errors?: any[] }>> => {
|
|
||||||
return apiClient.post<{ success_count: number; failure_count: number; errors?: any[] }>('/devices/bulk-create', { devices })
|
|
||||||
},
|
|
||||||
|
|
||||||
// Assign customers to device ports - new endpoint
|
|
||||||
assignCustomersToPorts: async (data: DevicePortsUpdateRequest): Promise<ApiResponse<DeviceComplete>> => {
|
|
||||||
const payload = data.ports.map(port => ({
|
|
||||||
new_customer_name: port.customer_name,
|
|
||||||
port_number: port.port_number,
|
|
||||||
bandwidth: port.bandwidth,
|
|
||||||
is_occupied: port.is_occupied,
|
|
||||||
status: port.status,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return apiClient.put<DeviceComplete>(`/device-details/${data.device_id}/bulk-update-customers-by-port`, { updates: payload })
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get device with port assignments - uses device-details endpoint
|
|
||||||
getDeviceWithPorts: async (deviceId: string): Promise<ApiResponse<DeviceComplete>> => {
|
|
||||||
return apiClient.get<DeviceComplete>(`/device-details/${deviceId}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get devices without connections (not used by fishbone or backbone)
|
|
||||||
getDevicesWithoutConnections: async (deviceTypes: string[]): Promise<ApiResponse<DevicesWithoutConnectionsResponse>> => {
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
params.append('device_types', deviceTypes.join(','))
|
|
||||||
|
|
||||||
return apiClient.get<DevicesWithoutConnectionsResponse>(`/device-details/without-connections?${params.toString()}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get devices without towers
|
|
||||||
getDevicesWithoutTowers: async (deviceTypes: string[]): Promise<ApiResponse<DevicesWithoutTowersResponse>> => {
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
params.append('device_types', deviceTypes.join(','))
|
|
||||||
|
|
||||||
return apiClient.get<DevicesWithoutTowersResponse>(`/device-details/without-towers?${params.toString()}`)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
import { Backbone } from '@/features/backbones/types/backbone'
|
|
||||||
import { Fishbone } from '@/features/fishbones/types/fishbone'
|
|
||||||
import { Olt } from '@/features/olts/types/olt'
|
|
||||||
import { Tower } from '@/features/towers/types/tower'
|
|
||||||
|
|
||||||
export interface Device {
|
|
||||||
id: string
|
|
||||||
device_code: string
|
|
||||||
device_type: string
|
|
||||||
longitude: number
|
|
||||||
latitude: number
|
|
||||||
address: string
|
|
||||||
port_amount: number
|
|
||||||
total_used_port: number
|
|
||||||
province?: string
|
|
||||||
city?: string
|
|
||||||
district?: string
|
|
||||||
status: 'active' | 'inactive' | 'maintenance'
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PortAssignment {
|
|
||||||
port_number: number
|
|
||||||
customer_name: string | null
|
|
||||||
is_occupied: boolean
|
|
||||||
status: "dyingGasp" | "los" | "on" | "off"
|
|
||||||
bandwidth: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeviceComplete {
|
|
||||||
id: string
|
|
||||||
olt: Olt | null
|
|
||||||
device_code: string
|
|
||||||
device_type: string
|
|
||||||
address: string
|
|
||||||
longitude: number
|
|
||||||
latitude: number
|
|
||||||
status: 'active' | 'inactive' | 'maintenance'
|
|
||||||
port_amount: number
|
|
||||||
port_used: number
|
|
||||||
port_available: number
|
|
||||||
customer_names: string[] | null
|
|
||||||
port_assignments: PortAssignment[]
|
|
||||||
province: string | null
|
|
||||||
city: string | null
|
|
||||||
district: string | null
|
|
||||||
image_urls: string[]
|
|
||||||
backbones: (Pick<Backbone, 'id' | 'backbone_code' | 'core_amount'> & {
|
|
||||||
is_start_device: boolean
|
|
||||||
connected_to: string
|
|
||||||
})[]
|
|
||||||
fishbones: (Pick<Fishbone, 'id' | 'fishbone_code' | 'core_amount' | 'backbone_code'> & {
|
|
||||||
is_start_device: boolean
|
|
||||||
connected_to: string
|
|
||||||
})[]
|
|
||||||
towers: (Omit<
|
|
||||||
Tower,
|
|
||||||
'latitude' | 'longitude' | 'device_code' | 'address' | 'created_at'
|
|
||||||
> & { distance_km: number })[]
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeviceCreateRequest {
|
|
||||||
device_code: string
|
|
||||||
device_type: string
|
|
||||||
longitude: number
|
|
||||||
latitude: number
|
|
||||||
port_amount?: number | null
|
|
||||||
status: 'active' | 'inactive' | 'maintenance'
|
|
||||||
province?: string
|
|
||||||
city?: string
|
|
||||||
district?: string
|
|
||||||
image_urls?: File[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeviceUpdateRequest extends Partial<DeviceCreateRequest> {
|
|
||||||
id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DevicesListResponse {
|
|
||||||
devices: Device[]
|
|
||||||
// Remove pagination fields since we get all data
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeviceResponse {
|
|
||||||
device: Device
|
|
||||||
}
|
|
||||||
|
|
||||||
// Frontend filtering state
|
|
||||||
export interface DeviceFilters {
|
|
||||||
search: string
|
|
||||||
status: string
|
|
||||||
device_type: string
|
|
||||||
port_status?: string // Add this new field
|
|
||||||
province?: string
|
|
||||||
city?: string
|
|
||||||
district?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeviceSorting {
|
|
||||||
field: keyof Device
|
|
||||||
direction: 'asc' | 'desc'
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PortAssignmentStatus = 'no_customer' | 'in_use'
|
|
||||||
|
|
||||||
// Add connection status from API
|
|
||||||
export type PortConnectionStatus = 'dyingGasp' | 'los' | 'on' | 'off'
|
|
||||||
|
|
||||||
export interface Port {
|
|
||||||
port_number: number
|
|
||||||
assignment_status: PortAssignmentStatus
|
|
||||||
connection_status?: PortConnectionStatus
|
|
||||||
customer_name?: string
|
|
||||||
is_occupied: boolean
|
|
||||||
bandwidth?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DevicePort extends Device {
|
|
||||||
ports?: Port[]
|
|
||||||
}
|
|
||||||
export interface DevicePortAssignmentRequest {
|
|
||||||
customer_name: string | null
|
|
||||||
port_number: number
|
|
||||||
is_occupied: boolean
|
|
||||||
status: "dyingGasp" | "los" | "on" | "off"
|
|
||||||
bandwidth: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DevicePortsUpdateRequest {
|
|
||||||
device_id: string
|
|
||||||
ports: DevicePortAssignmentRequest[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeviceBreakdown {
|
|
||||||
closure: {
|
|
||||||
count: number
|
|
||||||
devices: DeviceComplete[]
|
|
||||||
}
|
|
||||||
otb: {
|
|
||||||
count: number
|
|
||||||
devices: DeviceComplete[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DevicesWithoutConnectionsFilter {
|
|
||||||
criteria: string
|
|
||||||
device_types: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DevicesWithoutConnectionsResponse {
|
|
||||||
breakdown: DeviceBreakdown
|
|
||||||
devices: DeviceComplete[]
|
|
||||||
filter: DevicesWithoutConnectionsFilter
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DevicesWithoutTowersFilter {
|
|
||||||
criteria: string
|
|
||||||
device_types: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DevicesWithoutTowersResponse {
|
|
||||||
devices: DeviceComplete[]
|
|
||||||
filter: DevicesWithoutTowersFilter
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
export interface ParsedDevice {
|
|
||||||
device_code: string
|
|
||||||
device_type: string
|
|
||||||
longitude: number
|
|
||||||
latitude: number
|
|
||||||
port_amount?: number
|
|
||||||
status: 'active' | 'inactive' | 'maintenance'
|
|
||||||
province?: string
|
|
||||||
city?: string
|
|
||||||
district?: string
|
|
||||||
tower_id?: string
|
|
||||||
olt?: string
|
|
||||||
row: number
|
|
||||||
errors: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ValidationResult {
|
|
||||||
valid: ParsedDevice[]
|
|
||||||
invalid: ParsedDevice[]
|
|
||||||
totalRows: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const REQUIRED_HEADERS = ['device_code', 'device_type', 'longitude', 'latitude']
|
|
||||||
const VALID_DEVICE_TYPES = ['OTB', 'CLOSURE', 'ODP']
|
|
||||||
const VALID_STATUSES = ['active', 'inactive', 'maintenance']
|
|
||||||
|
|
||||||
export class CSVParser {
|
|
||||||
static parseCSV(csvContent: string): ParsedDevice[] {
|
|
||||||
const lines = csvContent.split('\n').filter(line => line.trim())
|
|
||||||
|
|
||||||
if (lines.length < 2) {
|
|
||||||
throw new Error('CSV file must contain at least a header row and one data row')
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = lines[0].split(',').map(h => h.trim().toLowerCase())
|
|
||||||
|
|
||||||
// Validate required headers
|
|
||||||
const missingHeaders = REQUIRED_HEADERS.filter(required =>
|
|
||||||
!headers.includes(required.toLowerCase())
|
|
||||||
)
|
|
||||||
|
|
||||||
if (missingHeaders.length > 0) {
|
|
||||||
throw new Error(`Missing required headers: ${missingHeaders.join(', ')}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const devices: ParsedDevice[] = []
|
|
||||||
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
|
||||||
const values = CSVParser.parseCSVLine(lines[i])
|
|
||||||
const device = CSVParser.parseDeviceRow(headers, values, i + 1)
|
|
||||||
devices.push(device)
|
|
||||||
}
|
|
||||||
|
|
||||||
return devices
|
|
||||||
}
|
|
||||||
|
|
||||||
private static parseCSVLine(line: string): string[] {
|
|
||||||
const result: string[] = []
|
|
||||||
let current = ''
|
|
||||||
let inQuotes = false
|
|
||||||
|
|
||||||
for (let i = 0; i < line.length; i++) {
|
|
||||||
const char = line[i]
|
|
||||||
|
|
||||||
if (char === '"') {
|
|
||||||
inQuotes = !inQuotes
|
|
||||||
} else if (char === ',' && !inQuotes) {
|
|
||||||
result.push(current.trim())
|
|
||||||
current = ''
|
|
||||||
} else {
|
|
||||||
current += char
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(current.trim())
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static parseDeviceRow(headers: string[], values: string[], rowNumber: number): ParsedDevice {
|
|
||||||
const device: ParsedDevice = {
|
|
||||||
device_code: '',
|
|
||||||
device_type: '',
|
|
||||||
longitude: 0,
|
|
||||||
latitude: 0,
|
|
||||||
status: 'active',
|
|
||||||
row: rowNumber,
|
|
||||||
errors: []
|
|
||||||
}
|
|
||||||
|
|
||||||
headers.forEach((header, index) => {
|
|
||||||
const value = values[index]?.replace(/^"|"$/g, '') || '' // Remove surrounding quotes
|
|
||||||
|
|
||||||
switch (header) {
|
|
||||||
case 'device_code':
|
|
||||||
device.device_code = value
|
|
||||||
if (!value) {
|
|
||||||
device.errors.push('Device code is required')
|
|
||||||
} else if (value.length < 3) {
|
|
||||||
device.errors.push('Device code must be at least 3 characters')
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'device_type':
|
|
||||||
device.device_type = value.toUpperCase()
|
|
||||||
if (!value) {
|
|
||||||
device.errors.push('Device type is required')
|
|
||||||
} else if (!VALID_DEVICE_TYPES.includes(value.toUpperCase())) {
|
|
||||||
device.errors.push(`Device type must be one of: ${VALID_DEVICE_TYPES.join(', ')}`)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'longitude':
|
|
||||||
const longitude = parseFloat(value)
|
|
||||||
if (!value || isNaN(longitude)) {
|
|
||||||
device.errors.push('Longitude is required and must be a valid number')
|
|
||||||
} else if (longitude < -180 || longitude > 180) {
|
|
||||||
device.errors.push('Longitude must be between -180 and 180')
|
|
||||||
} else {
|
|
||||||
device.longitude = longitude
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'latitude':
|
|
||||||
const latitude = parseFloat(value)
|
|
||||||
if (!value || isNaN(latitude)) {
|
|
||||||
device.errors.push('Latitude is required and must be a valid number')
|
|
||||||
} else if (latitude < -90 || latitude > 90) {
|
|
||||||
device.errors.push('Latitude must be between -90 and 90')
|
|
||||||
} else {
|
|
||||||
device.latitude = latitude
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'port_amount':
|
|
||||||
if (value) {
|
|
||||||
const portAmount = parseInt(value)
|
|
||||||
if (isNaN(portAmount) || portAmount < 0) {
|
|
||||||
device.errors.push('Port amount must be a non-negative number')
|
|
||||||
} else {
|
|
||||||
device.port_amount = portAmount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'status':
|
|
||||||
if (value) {
|
|
||||||
const status = value.toLowerCase()
|
|
||||||
if (VALID_STATUSES.includes(status)) {
|
|
||||||
device.status = status as 'active' | 'inactive' | 'maintenance'
|
|
||||||
} else {
|
|
||||||
device.errors.push(`Status must be one of: ${VALID_STATUSES.join(', ')}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'province':
|
|
||||||
device.province = value
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'city':
|
|
||||||
device.city = value
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'district':
|
|
||||||
device.district = value
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'tower_id':
|
|
||||||
device.tower_id = value
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'olt':
|
|
||||||
case 'olt_id':
|
|
||||||
device.olt = value
|
|
||||||
break
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Additional validation rules
|
|
||||||
if (device.device_type === 'CLOSURE' && device.port_amount && device.port_amount > 0) {
|
|
||||||
device.errors.push('CLOSURE devices should not have ports (port_amount should be 0 or empty)')
|
|
||||||
}
|
|
||||||
|
|
||||||
return device
|
|
||||||
}
|
|
||||||
|
|
||||||
static validateDevices(devices: ParsedDevice[]): ValidationResult {
|
|
||||||
// Check for duplicate device codes
|
|
||||||
const deviceCodes = new Set<string>()
|
|
||||||
const duplicates = new Set<string>()
|
|
||||||
|
|
||||||
devices.forEach(device => {
|
|
||||||
if (device.device_code) {
|
|
||||||
if (deviceCodes.has(device.device_code)) {
|
|
||||||
duplicates.add(device.device_code)
|
|
||||||
} else {
|
|
||||||
deviceCodes.add(device.device_code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mark devices with duplicate codes as invalid
|
|
||||||
if (duplicates.size > 0) {
|
|
||||||
devices.forEach(device => {
|
|
||||||
if (duplicates.has(device.device_code)) {
|
|
||||||
device.errors.push(`Duplicate device code: ${device.device_code}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: devices.filter(d => d.errors.length === 0),
|
|
||||||
invalid: devices.filter(d => d.errors.length > 0),
|
|
||||||
totalRows: devices.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static generateTemplate(): string {
|
|
||||||
const headers = [
|
|
||||||
'device_code',
|
|
||||||
'device_type',
|
|
||||||
'longitude',
|
|
||||||
'latitude',
|
|
||||||
'port_amount',
|
|
||||||
'status',
|
|
||||||
'province',
|
|
||||||
'city',
|
|
||||||
'district',
|
|
||||||
'tower_id',
|
|
||||||
'olt'
|
|
||||||
]
|
|
||||||
|
|
||||||
const sampleData = [
|
|
||||||
'DEV001,OTB,106.8456,-6.2088,8,active,DKI Jakarta,Jakarta Selatan,Kebayoran Baru,,',
|
|
||||||
'DEV002,CLOSURE,106.8500,-6.2100,,active,DKI Jakarta,Jakarta Selatan,Kebayoran Baru,,',
|
|
||||||
'DEV003,ODP,106.8600,-6.2200,4,active,DKI Jakarta,Jakarta Selatan,Kebayoran Baru,,'
|
|
||||||
]
|
|
||||||
|
|
||||||
return [headers.join(','), ...sampleData].join('\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient, ApiResponse } from '../../../lib/api-client';
|
import { apiClient, ApiResponse, extractApiErrors } from '../../../lib/api-client';
|
||||||
// import { toast } from 'react-hot-toast';
|
// import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
// Tipe untuk model User
|
// Tipe untuk model User
|
||||||
|
|
@ -68,12 +68,12 @@ export const useCreateUser = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
// toast.success('Pengguna berhasil dibuat');
|
// toast.success('Pengguna berhasil dibuat');
|
||||||
},
|
},
|
||||||
// onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
// const errors = extractApiErrors(error);
|
const errors = extractApiErrors(error);
|
||||||
// Object.values(errors).forEach((message) => {
|
Object.values(errors).forEach((message) => {
|
||||||
// // toast.error(message);
|
// toast.error(message);
|
||||||
// });
|
});
|
||||||
// },
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -94,12 +94,12 @@ export const useUpdateUser = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users', data.id] });
|
queryClient.invalidateQueries({ queryKey: ['users', data.id] });
|
||||||
// toast.success('Pengguna berhasil diperbarui');
|
// toast.success('Pengguna berhasil diperbarui');
|
||||||
},
|
},
|
||||||
// onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
// const errors = extractApiErrors(error);
|
const errors = extractApiErrors(error);
|
||||||
// Object.values(errors).forEach((message) => {
|
Object.values(errors).forEach((message) => {
|
||||||
// // toast.error(message);
|
// toast.error(message);
|
||||||
// });
|
});
|
||||||
// },
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -118,11 +118,11 @@ export const useDeleteUser = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
// toast.success('Pengguna berhasil dihapus');
|
// toast.success('Pengguna berhasil dihapus');
|
||||||
},
|
},
|
||||||
// onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
// const errors = extractApiErrors(error);
|
const errors = extractApiErrors(error);
|
||||||
// Object.values(errors).forEach((message) => {
|
Object.values(errors).forEach((message) => {
|
||||||
// // toast.error(message);
|
// toast.error(message);
|
||||||
// });
|
});
|
||||||
// },
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -30,7 +30,7 @@ apiClient.interceptors.response.use(
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
(error: AxiosError) => {
|
(error: AxiosError) => {
|
||||||
// const { response } = error;
|
const { response } = error;
|
||||||
|
|
||||||
// Handle expired token atau unauthorized
|
// Handle expired token atau unauthorized
|
||||||
// if (response?.status === 401) {
|
// if (response?.status === 401) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
import { useLogin } from "../../features/auth/api/auth";
|
import { useLogin } from "../../features/auth/api/auth";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import Input from "@/components/forms/Input";
|
import Input from "@/components/forms/Input";
|
||||||
|
|
@ -7,6 +8,7 @@ import Card from "@/components/data_display/Card/Card";
|
||||||
import Button from "@/components/actions/Button";
|
import Button from "@/components/actions/Button";
|
||||||
|
|
||||||
const LoginPage = () => {
|
const LoginPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const { ...login } = useLogin({
|
const { ...login } = useLogin({
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true,
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue