From 549e92649392d2a35bd03148a13b8b126b2d1610 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 10 Oct 2025 15:39:09 +0700 Subject: [PATCH] Resolve merge conflicts: accept incoming changes (theirs) --- .../devices/components/devices-dialogs.tsx | 18 ++ .../components/devices-primary-buttons.tsx | 28 ++ .../devices/context/devices-context.tsx | 265 ++++++++++++++++++ src/features/devices/hooks/use-devices.ts | 164 +++++++++++ src/features/devices/index.tsx | 122 ++++++++ .../devices/services/devices-service.ts | 85 ++++++ src/features/devices/types/device.ts | 168 +++++++++++ 7 files changed, 850 insertions(+) create mode 100644 src/features/devices/components/devices-dialogs.tsx create mode 100644 src/features/devices/components/devices-primary-buttons.tsx create mode 100644 src/features/devices/context/devices-context.tsx create mode 100644 src/features/devices/hooks/use-devices.ts create mode 100644 src/features/devices/index.tsx create mode 100644 src/features/devices/services/devices-service.ts create mode 100644 src/features/devices/types/device.ts diff --git a/src/features/devices/components/devices-dialogs.tsx b/src/features/devices/components/devices-dialogs.tsx new file mode 100644 index 0000000..b5b60e0 --- /dev/null +++ b/src/features/devices/components/devices-dialogs.tsx @@ -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 ( + <> + + + + + + + + ) +} diff --git a/src/features/devices/components/devices-primary-buttons.tsx b/src/features/devices/components/devices-primary-buttons.tsx new file mode 100644 index 0000000..a1d9eef --- /dev/null +++ b/src/features/devices/components/devices-primary-buttons.tsx @@ -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 ( +
+ + +
+ ) +} diff --git a/src/features/devices/context/devices-context.tsx b/src/features/devices/context/devices-context.tsx new file mode 100644 index 0000000..5bbb1ee --- /dev/null +++ b/src/features/devices/context/devices-context.tsx @@ -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(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([]) + + // Dialog states + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [isBulkCreateDialogOpen, setIsBulkCreateDialogOpen] = useState(false) + + // Current device + const [currentDevice, setCurrentDevice] = useState(null) + + // Frontend filtering and sorting + const [filters, setFilters] = useState(initialFilters) + const [sorting, setSorting] = useState(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(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 ( + + {children} + + ) +} + +export const useDevicesContext = () => { + const context = useContext(DevicesContext) + if (context === undefined) { + throw new Error('useDevicesContext must be used within a DevicesProvider') + } + return context +} diff --git a/src/features/devices/hooks/use-devices.ts b/src/features/devices/hooks/use-devices.ts new file mode 100644 index 0000000..f91592e --- /dev/null +++ b/src/features/devices/hooks/use-devices.ts @@ -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') + }, + }) +} diff --git a/src/features/devices/index.tsx b/src/features/devices/index.tsx new file mode 100644 index 0000000..7deb2dc --- /dev/null +++ b/src/features/devices/index.tsx @@ -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 ( +
+ + +
+ + +
+
+ ) + } else { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) + } + } + + return ( + <> +
+ +
+ + +
+
+ +
+
+
+

Perangkat

+

+ Berikut adalah daftar perangkat Anda! +

+
+
+ + +
+
+ + {/* Region Filter */} +
+ +
+ +
+ {isLoading ? ( + renderSkeletons() + ) : view === 'table' ? ( + + ) : ( + + )} +
+
+ + + + ) +} + +export default function Devices() { + return ( + + + + ) +} diff --git a/src/features/devices/services/devices-service.ts b/src/features/devices/services/devices-service.ts new file mode 100644 index 0000000..a60b4ae --- /dev/null +++ b/src/features/devices/services/devices-service.ts @@ -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> => { + return apiClient.get('/device-details') + }, + + // Get single device by ID + getDevice: async (id: string): Promise> => { + return apiClient.get(`/device-details/${id}`) + }, + + // Create new device + createDevice: async (data: FormData): Promise> => { + return apiClient.postFormData('/devices', data) + }, + + // Update device + updateDevice: async (id: string, data: FormData): Promise> => { + return apiClient.putFormData(`/device-details/${id}`, data) + }, + + // Delete device + deleteDevice: async (id: string): Promise> => { + return apiClient.delete(`/devices/${id}`) + }, + + // Delete device image - NEW + deleteDeviceImage: async (deviceId: string, imageName: string): Promise> => { + return apiClient.delete(`/device-details/${deviceId}/images/${imageName}`) + }, + + // Bulk delete devices + bulkDeleteDevices: async (ids: string[]): Promise> => { + return apiClient.post('/devices/bulk-delete', { ids }) + }, + + // Bulk create devices + bulkCreateDevices: async (devices: any[]): Promise> => { + 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> => { + 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(`/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> => { + return apiClient.get(`/device-details/${deviceId}`) + }, + + // Get devices without connections (not used by fishbone or backbone) + getDevicesWithoutConnections: async (deviceTypes: string[]): Promise> => { + const params = new URLSearchParams() + params.append('device_types', deviceTypes.join(',')) + + return apiClient.get(`/device-details/without-connections?${params.toString()}`) + }, + + // Get devices without towers + getDevicesWithoutTowers: async (deviceTypes: string[]): Promise> => { + const params = new URLSearchParams() + params.append('device_types', deviceTypes.join(',')) + + return apiClient.get(`/device-details/without-towers?${params.toString()}`) + }, +} diff --git a/src/features/devices/types/device.ts b/src/features/devices/types/device.ts new file mode 100644 index 0000000..bbca5f5 --- /dev/null +++ b/src/features/devices/types/device.ts @@ -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 & { + is_start_device: boolean + connected_to: string + })[] + fishbones: (Pick & { + 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 { + 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 +} \ No newline at end of file