Compare commits
11 Commits
a62dcf8b34
...
f470c96f5f
| Author | SHA1 | Date |
|---|---|---|
|
|
f470c96f5f | |
|
|
549e926493 | |
|
|
f4e955ade4 | |
|
|
8abed63508 | |
|
|
f7cabbe480 | |
|
|
ce25ad3d5c | |
|
|
0efef0f8e1 | |
|
|
3c68cdc9a5 | |
|
|
9ac85cde1a | |
|
|
bcef197e9a | |
|
|
694e681a10 |
|
|
@ -4,16 +4,14 @@ FROM node:18-alpine AS builder
|
|||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# Install dependencies
|
||||
# Copy package.json dan install dependencies
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy project files
|
||||
# Copy seluruh project
|
||||
COPY . .
|
||||
|
||||
# Build the project
|
||||
# Build project
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Run
|
||||
|
|
@ -22,13 +20,13 @@ FROM node:18-alpine
|
|||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built files from builder
|
||||
# Copy built files dari stage builder
|
||||
COPY --from=builder /app ./
|
||||
|
||||
# Install only production dependencies
|
||||
RUN npm ci --omit=dev
|
||||
# Install hanya dependencies produksi
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Expose the application port (sesuaikan dengan port yang digunakan di package.json)
|
||||
# Expose port sesuai dengan package.json (sesuaikan jika berbeda)
|
||||
EXPOSE 8000
|
||||
|
||||
# Jalankan aplikasi dengan npm run preview
|
||||
|
|
|
|||
|
|
@ -129,25 +129,24 @@ spec:
|
|||
app.kubernetes.io/name: nam-frontend-dev
|
||||
spec:
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: nam-frontend-dev
|
||||
app.kubernetes.io/name: nam-frontend-dev
|
||||
topologyKey: "kubernetes.io/hostname"
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: nam-frontend-dev
|
||||
app.kubernetes.io/name: nam-frontend-dev
|
||||
topologyKey: "kubernetes.io/hostname"
|
||||
containers:
|
||||
- name: web
|
||||
image: git.winteraccess.id/winter-access/frontend-nam:dev
|
||||
imagePullPolicy: Always
|
||||
env:
|
||||
- name: DB_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: nam-frontend-dev-secret
|
||||
key: DB_PASSWORD
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: nam-frontend-dev-config
|
||||
- secretRef:
|
||||
name: nam-frontend-dev-secret
|
||||
resources:
|
||||
limits:
|
||||
cpu: "250m"
|
||||
|
|
@ -168,8 +167,12 @@ spec:
|
|||
terminationMessagePath: /dev/termination-log
|
||||
terminationMessagePolicy: File
|
||||
volumeMounts:
|
||||
- mountPath: /app
|
||||
name: app
|
||||
- mountPath: /public
|
||||
name: public
|
||||
- mountPath: /storage
|
||||
name: storage
|
||||
- mountPath: /tmp
|
||||
name: tmp
|
||||
initContainers:
|
||||
- name: init
|
||||
image: git.winteraccess.id/winter-access/frontend-nam:dev
|
||||
|
|
@ -197,8 +200,12 @@ spec:
|
|||
terminationMessagePath: /dev/termination-log
|
||||
terminationMessagePolicy: File
|
||||
volumeMounts:
|
||||
- mountPath: /app
|
||||
name: app
|
||||
- mountPath: /public
|
||||
name: public
|
||||
- mountPath: /storage
|
||||
name: storage
|
||||
- mountPath: /tmp
|
||||
name: tmp
|
||||
imagePullSecrets:
|
||||
- name: winter-registry
|
||||
dnsPolicy: ClusterFirst
|
||||
|
|
@ -230,6 +237,8 @@ spec:
|
|||
- name: storage
|
||||
persistentVolumeClaim:
|
||||
claimName: nam-frontend-dev-storage
|
||||
|
||||
|
||||
---
|
||||
|
||||
apiVersion: v1
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ interface SidebarProps {
|
|||
handleLogout: () => void;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ sidebarOpen, user, handleLogout }) => {
|
||||
const Sidebar: React.FC<SidebarProps> = ({ sidebarOpen, user }) => {
|
||||
return (
|
||||
<aside
|
||||
className={`
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router';
|
||||
import { Outlet, useNavigate } from 'react-router';
|
||||
import { useAuthStore } from '../../features/auth/store';
|
||||
import Sidebar from './Dashboard/Sidebar'; // Import the Sidebar component we created earlier
|
||||
import Header from './Dashboard/Header';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,293 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
/* 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')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
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()}`)
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
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 { apiClient, ApiResponse, extractApiErrors } from '../../../lib/api-client';
|
||||
import { apiClient, ApiResponse } from '../../../lib/api-client';
|
||||
// import { toast } from 'react-hot-toast';
|
||||
|
||||
// Tipe untuk model User
|
||||
|
|
@ -68,12 +68,12 @@ export const useCreateUser = () => {
|
|||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
// toast.success('Pengguna berhasil dibuat');
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errors = extractApiErrors(error);
|
||||
Object.values(errors).forEach((message) => {
|
||||
// toast.error(message);
|
||||
});
|
||||
},
|
||||
// onError: (error: unknown) => {
|
||||
// const errors = extractApiErrors(error);
|
||||
// Object.values(errors).forEach((message) => {
|
||||
// // toast.error(message);
|
||||
// });
|
||||
// },
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -94,12 +94,12 @@ export const useUpdateUser = () => {
|
|||
queryClient.invalidateQueries({ queryKey: ['users', data.id] });
|
||||
// toast.success('Pengguna berhasil diperbarui');
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errors = extractApiErrors(error);
|
||||
Object.values(errors).forEach((message) => {
|
||||
// toast.error(message);
|
||||
});
|
||||
},
|
||||
// onError: (error: unknown) => {
|
||||
// const errors = extractApiErrors(error);
|
||||
// Object.values(errors).forEach((message) => {
|
||||
// // toast.error(message);
|
||||
// });
|
||||
// },
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -118,11 +118,11 @@ export const useDeleteUser = () => {
|
|||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
// toast.success('Pengguna berhasil dihapus');
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errors = extractApiErrors(error);
|
||||
Object.values(errors).forEach((message) => {
|
||||
// toast.error(message);
|
||||
});
|
||||
},
|
||||
// onError: (error: unknown) => {
|
||||
// const errors = extractApiErrors(error);
|
||||
// Object.values(errors).forEach((message) => {
|
||||
// // toast.error(message);
|
||||
// });
|
||||
// },
|
||||
});
|
||||
};
|
||||
|
|
@ -30,7 +30,7 @@ apiClient.interceptors.response.use(
|
|||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
const { response } = error;
|
||||
// const { response } = error;
|
||||
|
||||
// Handle expired token atau unauthorized
|
||||
// if (response?.status === 401) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useLogin } from "../../features/auth/api/auth";
|
||||
import axios from "axios";
|
||||
import Input from "@/components/forms/Input";
|
||||
|
|
@ -8,7 +7,6 @@ import Card from "@/components/data_display/Card/Card";
|
|||
import Button from "@/components/actions/Button";
|
||||
|
||||
const LoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const { ...login } = useLogin({
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@
|
|||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue