Resolve merge conflicts: accept incoming changes (theirs)

This commit is contained in:
unknown 2025-10-10 15:39:09 +07:00
parent f4e955ade4
commit 549e926493
7 changed files with 850 additions and 0 deletions

View File

@ -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>
</>
)
}

View File

@ -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>
)
}

View File

@ -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
}

View File

@ -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')
},
})
}

View File

@ -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>
)
}

View File

@ -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()}`)
},
}

View File

@ -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
}