Compare commits

...

11 Commits

Author SHA1 Message Date
unknown f470c96f5f Resolve merge conflicts 2025-10-10 15:40:05 +07:00
unknown 549e926493 Resolve merge conflicts: accept incoming changes (theirs) 2025-10-10 15:39:09 +07:00
hasanmu f4e955ade4 Merge pull request 'fix: remove unused code for build' (#1) from features/refactor into dev
Reviewed-on: winter-access/frontend-nam#1
2025-03-28 02:04:54 +00:00
rheiga19 8abed63508 fixing: kubernetes 2025-03-27 15:20:54 +07:00
rheiga19 f7cabbe480 Merge branch 'dev' of https://git.winteraccess.id/winter-access/frontend-nam into dev 2025-03-27 15:20:13 +07:00
rheiga19 ce25ad3d5c update kubernetes 2025-03-27 15:17:29 +07:00
rheiga23 0efef0f8e1 revert 3c68cdc9a5
revert fixing kubernetes
2025-03-27 06:23:13 +00:00
rheiga19 3c68cdc9a5 fixing kubernetes 2025-03-27 12:56:49 +07:00
rheiga19 9ac85cde1a fixing deployment build 2025-03-27 12:24:42 +07:00
rheiga19 bcef197e9a fixing dockerfile 2025-03-27 09:35:13 +07:00
HasanMu 694e681a10 fix: remove unused code for build 2025-03-26 21:43:50 +07:00
18 changed files with 1582 additions and 55 deletions

View File

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

View File

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

View File

@ -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={`

View File

@ -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';

View File

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

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

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
}

View File

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

View File

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

View File

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

View File

@ -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({

View File

@ -17,8 +17,8 @@
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,