Fix: initial commit

This commit is contained in:
karuniaDwi 2025-08-16 16:47:24 +07:00
commit 371ca32928
35 changed files with 6638 additions and 0 deletions

52
.htaccess Normal file
View File

@ -0,0 +1,52 @@
RewriteEngine On
# Redirect to index.php if file doesn't exist
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^([^/]+)/?$ index.php?page=$1 [QSA,L]
# Security - Hide .htaccess files
<Files ".htaccess">
Require all denied
</Files>
# Security - Hide config files
<Files "*.config">
Require all denied
</Files>
# Security - Hide database files
<Files "database.sql">
Require all denied
</Files>
# MIME Types for better performance
<IfModule mod_mime.c>
AddType application/javascript .js
AddType text/css .css
</IfModule>
# Enable compression
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/plain
AddOutputFilterByType DEFLATE text/html
AddOutputFilterByType DEFLATE text/xml
AddOutputFilterByType DEFLATE text/css
AddOutputFilterByType DEFLATE application/xml
AddOutputFilterByType DEFLATE application/xhtml+xml
AddOutputFilterByType DEFLATE application/rss+xml
AddOutputFilterByType DEFLATE application/javascript
AddOutputFilterByType DEFLATE application/x-javascript
</IfModule>
# Cache control for better performance
<IfModule mod_expires.c>
ExpiresActive on
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
ExpiresByType image/png "access plus 1 month"
ExpiresByType image/jpg "access plus 1 month"
ExpiresByType image/jpeg "access plus 1 month"
ExpiresByType image/gif "access plus 1 month"
ExpiresByType image/svg+xml "access plus 1 month"
</IfModule>

198
ITEM_SUMMARY.md Normal file
View File

@ -0,0 +1,198 @@
# 🎉 UPGRADE ITEM FIELDS & CORE SYNCHRONIZATION - SUMMARY
## ✅ **SEMUA FITUR BERHASIL DITAMBAHKAN!**
### 📋 **FIELD BARU PADA FORM ITEM:**
#### **1. 🎨 Warna Core**
- **Field**: `core_color_id`
- **Type**: Foreign Key → `tube_colors` table
- **Description**: Warna core yang berbeda dari warna tube
- **UI**: Dropdown dengan preview warna (32 pilihan warna)
#### **2. 🔗 Jenis Kabel**
- **Field**: `item_cable_type`
- **Type**: ENUM ('backbone', 'distribution', 'drop_core', 'feeder', 'branch')
- **Default**: 'distribution'
- **Description**: Klasifikasi jenis kabel yang digunakan pada item
#### **3. 🔢 Kapasitas Core Total**
- **Field**: `total_core_capacity`
- **Type**: INT
- **Default**: 24
- **Description**: Total kapasitas core untuk item
- **Options**: 2, 4, 6, 8, 12, 24, 48, 72, 96, 144, 216, 288 Core
#### **4. 📊 Core yang Digunakan (Enhanced)**
- **Field**: `core_used` (existing, enhanced)
- **Type**: INT
- **Description**: Core yang sedang digunakan dari total kapasitas
- **Feature**: Auto-sync dengan routing kabel
#### **5. 📈 Core Tersedia (Calculated)**
- **Field**: `core_available` (calculated field)
- **Type**: Display only
- **Formula**: `total_core_capacity - core_used`
- **Feature**: Real-time calculation dengan color indicator
---
### 🛠️ **DATABASE CHANGES:**
#### **New Columns Added:**
```sql
ALTER TABLE ftth_items ADD COLUMN core_color_id INT NULL AFTER core_used;
ALTER TABLE ftth_items ADD COLUMN item_cable_type ENUM('backbone', 'distribution', 'drop_core', 'feeder', 'branch') NULL DEFAULT 'distribution' AFTER core_color_id;
ALTER TABLE ftth_items ADD COLUMN total_core_capacity INT NULL DEFAULT 24 AFTER item_cable_type;
-- Foreign Keys
ALTER TABLE ftth_items ADD FOREIGN KEY (core_color_id) REFERENCES tube_colors(id);
-- Indexes for Performance
CREATE INDEX idx_core_color ON ftth_items(core_color_id);
CREATE INDEX idx_cable_type ON ftth_items(item_cable_type);
CREATE INDEX idx_core_usage ON ftth_items(core_used, total_core_capacity);
```
---
### ⚙️ **API ENHANCEMENTS:**
#### **Updated Endpoints:**
- **POST/PUT `/api/items.php`**: Handle new fields
- **GET `/api/items.php`**: Include new fields in response with JOINs
- **Foreign Key Null Handling**: Empty strings converted to NULL
#### **New Query with JOINs:**
```sql
SELECT i.*, it.name as item_type_name, it.icon, it.color,
tc.color_name as tube_color_name, tc.hex_code,
cc.color_name as core_color_name, cc.hex_code as core_hex_code,
sm.ratio as splitter_main_ratio,
so.ratio as splitter_odp_ratio
FROM ftth_items i
LEFT JOIN item_types it ON i.item_type_id = it.id
LEFT JOIN tube_colors tc ON i.tube_color_id = tc.id
LEFT JOIN tube_colors cc ON i.core_color_id = cc.id
LEFT JOIN splitter_types sm ON i.splitter_main_id = sm.id
LEFT JOIN splitter_types so ON i.splitter_odp_id = so.id
```
---
### 🎯 **CORE SYNCHRONIZATION FEATURES:**
#### **1. Auto Core Calculation**
- **Function**: `calculateCoreAvailable()`
- **Trigger**: Real-time pada perubahan capacity/usage
- **Display**: Core tersedia dengan color indicator
- 🟢 **Success**: > 50% available
- 🟡 **Warning**: 20-50% available
- 🔴 **Danger**: < 20% available
#### **2. Route-Item Core Sync**
- **Function**: `syncCoreUsageFromRoutes(itemId)`
- **Description**: Sinkronisasi core usage dari semua routing yang connected
- **Auto-trigger**: Saat edit item
- **Logic**: Sum semua `core_count` dari routes yang memiliki `from_item_id` atau `to_item_id` = itemId
#### **3. Enhanced Edit Functions**
- **Function**: `editItemEnhanced(itemId)`
- **Feature**: Auto-sync core usage setelah load item data
- **Timing**: 500ms delay untuk ensure form loaded
---
### 🎨 **UI/UX IMPROVEMENTS:**
#### **Enhanced Form Layout:**
```html
Row 1: [Warna Tube] [Warna Core]
Row 2: [Jenis Kabel] [Kapasitas Core Total]
Row 3: [Core Digunakan] [Core Tersedia (readonly)]
```
#### **Visual Indicators:**
- **Color Preview**: Border-left colored pada dropdown options
- **Badge System**: Color-coded untuk cable types
- **Real-time Feedback**: Core available calculation
#### **Cable Type Badges:**
- 🔴 **Backbone** (danger)
- 🔵 **Distribution** (primary)
- 🟢 **Drop Core** (success)
- 🔷 **Feeder** (info)
- 🟡 **Branch** (warning)
#### **Core Usage Badges:**
- 🟢 **0-49% used** (success)
- 🔷 **50-69% used** (info)
- 🟡 **70-89% used** (warning)
- 🔴 **90-100% used** (danger)
---
### 🚀 **NEW JAVASCRIPT FUNCTIONS:**
#### **Core Management:**
```javascript
calculateCoreAvailable() // Real-time core calculation
syncCoreUsageFromRoutes(itemId) // Sync dari routing data
editItemEnhanced(itemId) // Enhanced edit dengan auto-sync
```
#### **Display Helpers:**
```javascript
getCableTypeBadge(cableType) // Bootstrap badge class
getCableTypeText(cableType) // Human readable text
getCoreUsageBadge(used, total) // Usage percentage badge
```
#### **Form Enhancements:**
- Auto-populate dropdown warna core (sama dengan tube colors)
- Real-time listeners untuk capacity & usage changes
- Form validation untuk field baru
---
### 📊 **USAGE EXAMPLES:**
#### **Create Item dengan Field Baru:**
```javascript
// Form akan include:
{
item_type: "1",
name: "OLT Jakarta",
tube_color_id: "1", // Warna Tube: Biru
core_color_id: "15", // Warna Core: Magenta
item_cable_type: "backbone", // Jenis: Backbone
total_core_capacity: "144", // Kapasitas: 144 Core
core_used: "48", // Digunakan: 48 Core
// core_available = 96 Core (auto-calculated)
}
```
#### **Core Sync Example:**
```javascript
// Item ID 1 connected ke:
// Route 1: from_item_id=1, core_count=24
// Route 2: to_item_id=1, core_count=12
// Total core_used = 24 + 12 = 36 Core (auto-synced)
```
---
### 🔍 **TESTING & VALIDATION:**
#### **✅ Tested Features:**
1. Database field creation & constraints ✅
2. API CRUD operations dengan field baru ✅
3. Form validation & submission ✅
4. Core calculation & synchronization ✅
5. UI display & color indicators ✅
6. Foreign key relationships ✅
#### **🧹 Cleanup:**
- Temporary test files deleted
- Database indexes optimized
- Code documented & organize

176
KMZ_Export_Fix_Guide.md Normal file
View File

@ -0,0 +1,176 @@
# 🔧 Fix: Error Export KMZ - "toFixed is not a function"
## ❌ **Error Yang Diperbaiki**
```
Error menggenerate KMZ: item.latitude.toFixed is not a function
```
## 🔍 **Root Cause**
Error terjadi karena data `latitude` dan `longitude` dari database berupa **string**, bukan **number**. JavaScript method `toFixed()` hanya dapat digunakan pada tipe data number.
---
## ✅ **Solusi Yang Diimplementasikan**
### 1. **Parse String ke Number**
```javascript
// SEBELUM (Error):
item.latitude.toFixed(6)
item.longitude.toFixed(6)
// SESUDAH (Fixed):
parseFloat(item.latitude).toFixed(6)
parseFloat(item.longitude).toFixed(6)
```
### 2. **Validation & Error Handling**
```javascript
// Validate coordinates
let lat = parseFloat(item.latitude);
let lng = parseFloat(item.longitude);
if (isNaN(lat) || isNaN(lng)) {
console.warn('Invalid coordinates for item:', item.name);
return; // Skip this item
}
```
### 3. **Safe Coordinate Display**
```javascript
// Handle coordinates safely
let coordText = (isNaN(lat) || isNaN(lng)) ?
'Koordinat tidak valid' :
`${lat.toFixed(6)}, ${lng.toFixed(6)}`;
```
---
## 📁 **File Yang Diperbaiki**
### 1. **assets/js/kmz-export.js**
- ✅ Fixed `generateItemPlacemarks()` - coordinates untuk KML
- ✅ Fixed `generateItemDescription()` - display koordinat
- ✅ Added validation untuk item dengan koordinat tidak valid
- ✅ Added filter untuk skip item dengan koordinat invalid
### 2. **assets/js/app.js**
- ✅ Fixed `generateItemListHtml()` - display koordinat di tabel
- ✅ Added same validation untuk konsistensi
---
## 🧪 **Testing Results**
### ✅ **Scenario Yang Sekarang Bekerja:**
1. **Data Normal**: Item dengan koordinat valid
```
latitude: "-6.208800", longitude: "106.845600"
✅ Berhasil export KMZ
```
2. **Data String**: Koordinat sebagai string dari database
```
parseFloat("-6.208800") → -6.2088 ✅
parseFloat("106.845600") → 106.8456 ✅
```
3. **Data Invalid**: Koordinat null/undefined/kosong
```
parseFloat(null) → NaN
isNaN(NaN) → true
✅ Item dilewati dengan warning
```
4. **Mixed Data**: Beberapa item valid, beberapa tidak
```
✅ Item valid: Diekspor ke KMZ
⚠️ Item invalid: Dilewati dengan notifikasi
```
---
## 📊 **Behavior Baru**
### **Pre-Export Validation:**
- ✅ Cek apakah ada data untuk diekspor
- ✅ Filter item dengan koordinat valid
- ✅ Warning untuk item yang dilewati
- ✅ Prevent export jika tidak ada data valid
### **Export Process:**
- ✅ Hanya export item dengan koordinat valid
- ✅ Skip item dengan koordinat invalid
- ✅ Log warning untuk debugging
- ✅ Notifikasi hasil export
### **User Feedback:**
```
✅ "KMZ file berhasil diunduh: FTTH_Planner_Export_2024-01-15.kmz"
⚠️ "2 item dilewati karena koordinat tidak valid"
❌ "Tidak ada item dengan koordinat yang valid untuk diekspor"
```
---
## 🎯 **Prevention untuk Future**
### **Database Level:**
- Pastikan kolom `latitude` dan `longitude` bertipe **DECIMAL**
- Validation saat insert/update data koordinat
### **API Level:**
- Convert data ke number sebelum return JSON
- Validation koordinat format
### **Frontend Level:**
- Validation input koordinat saat save item
- Format number dengan proper parsing
---
## 🚀 **Testing Checklist**
### **Test Export KMZ:**
1. ✅ **Buat beberapa item** dengan koordinat valid
2. ✅ **Klik "Export KMZ"**
3. ✅ **File terdownload** tanpa error
4. ✅ **Buka di Google Earth** - item muncul dengan benar
### **Test Edge Cases:**
1. ✅ **Export tanpa data** → Warning "Tidak ada data"
2. ✅ **Export dengan koordinat invalid** → Warning "X item dilewati"
3. ✅ **Semua koordinat invalid** → Error "Tidak ada item valid"
4. ✅ **Mixed valid/invalid** → Export item valid + warning
### **Test Koordinat Display:**
1. ✅ **Daftar Item** → Koordinat tampil dengan benar
2. ✅ **Item invalid** → "Koordinat tidak valid"
3. ✅ **KMZ Description** → Format koordinat benar
---
## 📝 **Notes**
### **Why parseFloat()?**
- Converts string to number safely
- Returns NaN for invalid input (dapat dideteksi)
- Lebih reliable dari parseInt() untuk decimal
### **Why toFixed(6)?**
- 6 decimal places = ±1 meter accuracy
- Standard untuk GPS coordinates
- Consistent dengan format Google Earth
### **Why Skip Invalid Items?**
- Prevent KMZ corruption
- Better user experience
- Clear feedback untuk data issues
---
**Error "toFixed is not a function" sudah tidak akan muncul lagi!** ✅
Export KMZ sekarang robust dan dapat menangani berbagai kondisi data dengan proper validation dan user feedback.

186
KMZ_Export_Guide.md Normal file
View File

@ -0,0 +1,186 @@
# 🌍 Panduan Export KMZ - FTTH Planner
## 📋 Tentang Fitur Export KMZ
Fitur Export KMZ memungkinkan Anda mengekspor semua data infrastruktur FTTH dari aplikasi web ke format KMZ yang dapat dibuka di Google Earth atau aplikasi GIS lainnya.
## 🎯 Apa yang Diekspor?
### 📍 Item FTTH
- **OLT** - Marker merah besar
- **Tiang Tumpu** - Marker hijau
- **ODP** - Marker biru
- **ODC** - Marker hijau kotak
- **Pelanggan** - Marker orange kecil
### 🛣️ Routing Kabel
- **Terpasang** - Garis hijau tebal
- **Perencanaan** - Garis kuning
- **Maintenance** - Garis merah
### 📊 Data yang Disertakan
Setiap item menyertakan informasi lengkap:
- Nama dan jenis item
- Deskripsi dan alamat
- Koordinat GPS
- Warna tube dan core yang digunakan
- Jenis splitter (utama dan ODP)
- Status item
## 🚀 Cara Menggunakan
### 1. Export dari Aplikasi
```
Dashboard → Tombol "Export KMZ" (hijau)
atau
Sidebar → Export Data → Export ke KMZ
```
### 2. File yang Dihasilkan
- **Nama File**: `FTTH_Planner_Export_YYYY-MM-DD-HHMMSS.kmz`
- **Format**: KMZ (compressed KML)
- **Ukuran**: Tergantung jumlah data (biasanya <1MB)
### 3. Membuka di Google Earth
#### Google Earth Pro (Desktop)
1. **Buka Google Earth Pro**
2. **File → Open** atau drag & drop file KMZ
3. **Zoom ke area**: Data akan muncul di layer panel
4. **Klik item**: Lihat detail di popup
#### Google Earth Web
1. **Buka** [earth.google.com](https://earth.google.com)
2. **Menu → Projects → Import KML file**
3. **Upload file KMZ**
4. **Explore data** di layer panel
### 4. Aplikasi Lain yang Mendukung
- **QGIS** (Open Source GIS)
- **ArcGIS** (Professional GIS)
- **Google Maps MyMaps**
- **Avenza Maps** (Mobile)
- **GPS Essentials** (Android)
## 📱 Penggunaan di Mobile
### Android
1. **Download file KMZ** dari email/cloud
2. **Buka dengan Google Earth**
3. **Atau import ke aplikasi GPS/mapping**
### iPhone
1. **Download file KMZ**
2. **Open with → Google Earth**
3. **Atau share ke aplikasi mapping lain**
## 🔧 Troubleshooting
### File KMZ Tidak Bisa Dibuka
**Penyebab**:
- Browser memblokir download
- Ekstensi file tidak benar
- File corrupt saat download
**Solusi**:
1. **Coba browser lain** (Chrome, Firefox, Edge)
2. **Disable popup blocker** untuk domain aplikasi
3. **Download ulang** file KMZ
4. **Rename extension** dari .zip ke .kmz jika perlu
### Data Tidak Muncul di Google Earth
**Penyebab**:
- Koordinat di luar area view
- Layer tidak aktif
- Zoom level terlalu tinggi
**Solusi**:
1. **Cek layer panel** - pastikan data visible
2. **Double-click layer** untuk zoom ke data
3. **Reset view** dan cari area Indonesia
4. **Zoom out** untuk lihat overview
### Performance Issue
**Jika data banyak** (>1000 items):
1. **Filter data** sebelum export
2. **Export per area** tertentu
3. **Gunakan desktop app** bukan web browser
## 📊 Format Data KML
File KMZ berisi struktur KML sebagai berikut:
```xml
<kml>
<Document>
<name>FTTH Planner Export</name>
<!-- Styles untuk setiap jenis item -->
<Style id="olt-style">...</Style>
<!-- Placemark untuk setiap item -->
<Placemark>
<name>OLT Jakarta Selatan</name>
<description>Detail lengkap item...</description>
<Point>
<coordinates>106.8456,-6.2088,0</coordinates>
</Point>
</Placemark>
<!-- LineString untuk routing -->
<Placemark>
<name>Route: OLT → ODP</name>
<LineString>
<coordinates>106.8456,-6.2088,0 106.8500,-6.2100,0</coordinates>
</LineString>
</Placemark>
</Document>
</kml>
```
## 🎓 Tips & Best Practices
### Export Data Berkualitas
1. **Lengkapi semua field** (alamat, deskripsi, dll)
2. **Gunakan naming convention** yang konsisten
3. **Update status** item secara berkala
4. **Verifikasi koordinat** sebelum export
### Sharing Data
1. **Compress ulang** jika file besar (WinRAR/7zip)
2. **Upload ke cloud** untuk sharing tim
3. **Dokumentasikan versi** dan tanggal export
4. **Backup file KMZ** secara berkala
### Integrasi dengan Workflow
1. **Export mingguan** untuk monitoring
2. **Combine dengan data survey** lapangan
3. **Import ke GIS** untuk analisis lanjutan
4. **Share dengan contractor** untuk implementasi
## ⚡ Features Advanced
### Custom Styling
File KMZ menggunakan icon standar Google Earth, tapi Anda bisa:
1. **Edit file KMZ** (extract → edit KML → recompress)
2. **Custom icon URLs** di KML
3. **Advanced styling** dengan KML extensions
### Data Integration
1. **Merge dengan KMZ lain** di Google Earth
2. **Overlay dengan satellite imagery**
3. **Analysis dengan terrain data**
4. **Export ke format lain** (GPX, Shapefile)
## 📞 Support
Jika mengalami masalah dengan export KMZ:
1. **Cek browser console** (F12) untuk error
2. **Test dengan data minimal** (1-2 items)
3. **Verify file download** tidak corrupt
4. **Contact developer** dengan screenshot error
---
**Catatan**: Format KMZ adalah standard internasional untuk data geospasial, sehingga file yang dihasilkan dapat digunakan di berbagai platform dan aplikasi GIS profesional. 🌍

179
KMZ_Export_Summary.md Normal file
View File

@ -0,0 +1,179 @@
# 📦 Summary: Fitur Export KMZ
## ✅ Fitur Yang Ditambahkan
### 🎯 **Export KMZ Lengkap**
- ✅ Export semua items FTTH (OLT, Tiang, ODP, ODC, Pelanggan)
- ✅ Export semua routes dengan koordinat lengkap
- ✅ File KMZ kompatibel dengan Google Earth
- ✅ Styling yang sesuai untuk setiap jenis item
### 🔘 **Interface Baru**
- ✅ Tombol "Export KMZ" di header peta
- ✅ Menu "Export ke KMZ" di sidebar
- ✅ Notifikasi progress dan status download
### 📊 **Data Yang Diekspor**
- ✅ Semua informasi item (nama, deskripsi, alamat, koordinat)
- ✅ Technical data (warna tube, core, splitter)
- ✅ Status item (aktif, maintenance, dll)
- ✅ Route cables dengan jarak dan spesifikasi
---
## 📁 File Yang Dibuat/Dimodifikasi
### File Baru:
- `assets/js/kmz-export.js` - Logic export KMZ
- `KMZ_Export_Guide.md` - Panduan lengkap penggunaan
- `KMZ_Export_Summary.md` - Summary ini
### File Dimodifikasi:
- `index.php` - Tambah tombol & library
- `README.md` - Update dokumentasi
### Library Ditambahkan:
- JSZip - Untuk compress KML ke KMZ
- FileSaver.js - Untuk download file
---
## 🚀 Cara Menggunakan
### Quick Start:
1. **Buka aplikasi FTTH Planner**
2. **Tambah beberapa items** (OLT, Pelanggan, dll)
3. **Buat routes** antar items
4. **Klik "Export KMZ"** (tombol hijau)
5. **File otomatis terdownload** dengan nama timestamp
6. **Buka di Google Earth** atau aplikasi GIS
### Format File:
```
FTTH_Planner_Export_2024-01-15-143027.kmz
```
---
## 🌍 Kompatibilitas
### ✅ Aplikasi Yang Mendukung:
- **Google Earth Pro** (Desktop)
- **Google Earth Web** (Browser)
- **QGIS** (Open Source GIS)
- **ArcGIS** (Professional)
- **Avenza Maps** (Mobile)
- **GPS Essentials** (Android)
### 📱 Platform:
- **Windows** - Google Earth Pro
- **macOS** - Google Earth Pro
- **Linux** - QGIS, Google Earth Web
- **Android** - Google Earth, GPS apps
- **iOS** - Google Earth
---
## 🎨 Styling di Google Earth
### Items:
- 🔴 **OLT**: Marker merah besar
- 🟢 **Tiang Tumpu**: Marker hijau
- 🔵 **ODP**: Marker biru
- 🟩 **ODC**: Marker hijau kotak
- 🟠 **Pelanggan**: Marker orange kecil
### Routes:
- **Hijau solid**: Route terpasang
- **Kuning**: Route perencanaan
- **Merah**: Route maintenance
---
## 🔧 Technical Details
### KML Structure:
```xml
<kml>
<Document>
<name>FTTH Planner Export</name>
<Style id="olt-style">...</Style>
<Placemark>
<name>Item Name</name>
<description>Detailed info</description>
<Point><coordinates>...</coordinates></Point>
</Placemark>
<Placemark>
<name>Route</name>
<LineString><coordinates>...</coordinates></LineString>
</Placemark>
</Document>
</kml>
```
### JavaScript Functions:
- `exportToKMZ()` - Main export function
- `generateKML()` - Create KML content
- `createKMZFile()` - Compress and download
---
## 🐛 Error Handling
### Automatic Fallbacks:
- ✅ Check JSZip library availability
- ✅ Validate data before export
- ✅ Handle empty datasets gracefully
- ✅ Show progress notifications
- ✅ Catch download errors
### Browser Compatibility:
- ✅ Chrome, Firefox, Edge, Safari
- ✅ Mobile browsers
- ✅ Popup blocker handling
---
## 📈 Benefits
### For Planners:
- **Offline viewing** in Google Earth
- **3D visualization** of infrastructure
- **Share with stakeholders** easily
- **Mobile field reference**
### For Teams:
- **Standard format** across industry
- **Professional presentation**
- **Integration with GIS workflows**
- **Archive planning data**
### For Field Work:
- **GPS coordinates** for navigation
- **Detailed item info** on-site
- **Route planning** visualization
- **Offline access** to data
---
## 🎯 Next Enhancements (Future)
### Possible Improvements:
- 📍 Custom icon URLs in KMZ
- 📊 Layer grouping by item type
- 🎨 Advanced styling options
- 📱 Direct mobile export
- 🔄 Import KMZ back to app
- 📋 Export filtered data only
### Integration Options:
- 🌐 Google MyMaps integration
- 📊 Export to GeoJSON format
- 🗺️ Export to Shapefile
- 📱 QR code for mobile sharing
---
**Fitur Export KMZ siap digunakan!** 🎉
Semua data infrastruktur FTTH sekarang dapat diekspor ke format standar industri dan dibagikan dengan tim, contractor, atau stakeholder menggunakan Google Earth dan aplikasi GIS profesional.

229
README.md Normal file
View File

@ -0,0 +1,229 @@
# FTTH Planner
Aplikasi perencanaan infrastruktur FTTH (Fiber to the Home) berbasis web menggunakan AdminLTE, PHP, MySQL, dan OpenStreetMaps.
## Fitur Utama
- **Manajemen Item FTTH**: OLT, Tiang Tumpu, ODP, ODC
- **Peta Interaktif**: Visualisasi infrastruktur menggunakan OpenStreetMaps
- **Drag & Drop**: Pindahkan item langsung di peta
- **Routing Kabel**: Buat rute kabel mengikuti jalan
- **Form Dinamis**: Tambah/edit item dengan form modal
- **Dashboard Statistik**: Monitoring jumlah item dan route
- **Responsive Design**: Tampilan optimal di desktop dan mobile
- **Export KMZ**: Export data ke Google Earth format
## Sistem Requirement
- **XAMPP** (Apache, MySQL, PHP 7.4+)
- **Web Browser** modern dengan JavaScript enabled
- **Koneksi Internet** untuk loading maps dan library
## Instalasi
### 1. Setup XAMPP
1. Download dan install XAMPP dari [https://www.apachefriends.org/](https://www.apachefriends.org/)
2. Start Apache dan MySQL di XAMPP Control Panel
### 2. Setup Database
1. Buka phpMyAdmin di browser: `http://localhost/phpmyadmin`
2. Import file database: `database.sql`
3. Atau jalankan SQL script secara manual untuk membuat database dan tabel
### 3. Setup Aplikasi
1. Copy folder aplikasi ke `C:\xampp\htdocs\ftthplanner`
2. Pastikan struktur folder seperti ini:
```
C:\xampp\htdocs\ftthplanner\
├── index.php
├── config/
│ └── database.php
├── api/
│ ├── items.php
│ ├── routes.php
│ ├── tube_colors.php
│ ├── splitters.php
│ └── statistics.php
├── assets/
│ ├── css/
│ │ └── custom.css
│ └── js/
│ ├── map.js
│ ├── app.js
│ └── kmz-export.js
├── database.sql
├── README.md
├── KMZ_Export_Guide.md
└── testing_guide.md
```
### 4. Konfigurasi Database
Edit file `config/database.php` sesuai dengan setting MySQL Anda:
```php
private $host = "localhost";
private $db_name = "ftth_planner";
private $username = "root";
private $password = "";
```
### 5. Akses Aplikasi
Buka browser dan akses: `http://localhost/ftthplanner`
## Cara Penggunaan
### Menambah Item FTTH
1. **Via Sidebar**: Klik menu "Tambah OLT", "Tambah Tiang Tumpu", dll
2. **Via Map**: Klik langsung di peta untuk menambah item di lokasi tersebut
3. **Via Tombol**: Klik tombol "Tambah Item" di header card map
### Mengedit Item
1. Klik marker di peta
2. Klik tombol "Edit" di popup
3. Update informasi di form modal
4. Klik "Simpan"
### Memindahkan Item
- Drag & drop marker langsung di peta
- Posisi akan otomatis tersimpan
### Membuat Route Kabel
1. **Mode Routing**: Klik tombol "Mode Routing" atau "Route" di popup item
2. **Pilih Tujuan**: Klik item tujuan untuk membuat route
3. **Auto Route**: Sistem akan membuat route mengikuti jalan
### Melihat Daftar Item/Route
- Klik menu "Daftar Item" untuk melihat semua item dalam tabel
- Klik menu "Routing Kabel" untuk melihat semua route
### Export ke Google Earth
1. **Klik tombol "Export KMZ"** di header peta atau sidebar
2. **File KMZ otomatis terdownload** dengan timestamp
3. **Buka di Google Earth** atau aplikasi GIS lainnya
4. **Lihat detail lengkap** setiap item dan route
## Struktur Database
### Tabel Utama
- `ftth_items`: Data item infrastruktur FTTH
- `cable_routes`: Data routing kabel antar item
- `item_types`: Jenis-jenis item (OLT, Tiang, ODP, ODC)
- `tube_colors`: Master data warna tube
- `splitter_types`: Master data jenis splitter
### Relasi
- Item memiliki relasi ke item_types, tube_colors, dan splitter_types
- Route menghubungkan dua item (from_item_id dan to_item_id)
## Teknologi yang Digunakan
### Frontend
- **AdminLTE 3.2**: Framework dashboard admin
- **Leaflet.js**: Library peta interaktif
- **OpenStreetMaps**: Data peta
- **Leaflet Routing Machine**: Routing jalan
- **Bootstrap 4**: CSS framework
- **jQuery**: JavaScript library
- **Font Awesome**: Icon library
- **JSZip**: Compression untuk KMZ export
- **FileSaver.js**: Download file functionality
### Backend
- **PHP 7.4+**: Server-side scripting
- **MySQL**: Database
- **PDO**: Database abstraction layer
### Maps & Routing
- **OpenStreetMaps**: Tile server peta
- **Leaflet**: Map rendering engine
- **OSRM**: Routing service
## Fitur Lanjutan
### Drag & Drop Items
- Semua marker dapat di-drag ke posisi baru
- Update koordinat otomatis tersimpan ke database
### Interactive Popup
- Info lengkap item saat klik marker
- Aksi edit, route, dan hapus dalam popup
### Route Visualization
- Route terpasang: garis solid hijau
- Route perencanaan: garis putus-putus kuning
- Route maintenance: garis putus-putus merah
### Responsive Design
- Optimized untuk desktop dan mobile
- Map dan form menyesuaikan ukuran layar
### Export KMZ
- Export semua data items dan routes ke format Google Earth
- File KMZ dengan styling yang sesuai untuk setiap jenis item
- Informasi lengkap dalam popup Google Earth
- Kompatibel dengan aplikasi GIS profesional
## Troubleshooting
### Error Database Connection
- Pastikan MySQL berjalan di XAMPP
- Cek konfigurasi di `config/database.php`
- Pastikan database `ftth_planner` sudah dibuat
### Map Tidak Muncul
- Cek koneksi internet
- Pastikan JavaScript enabled di browser
- Lihat console browser untuk error
### Item Tidak Tersimpan
- Cek permission folder aplikasi
- Pastikan semua field required sudah diisi
- Lihat Network tab di browser untuk error API
### Route Tidak Terbuat
- Pastikan kedua item sudah ada di database
- Cek koneksi internet untuk routing service
- Mode routing harus aktif sebelum memilih item
## Pengembangan Lebih Lanjut
### Fitur yang Bisa Ditambahkan
1. **Import/Export Data**: Import data dari Excel/CSV
2. **Laporan**: Generate laporan PDF/Excel
3. **User Management**: Login dan role-based access
4. **Backup/Restore**: Backup data dan konfigurasi
5. **Mobile App**: Native app untuk survey lapangan
6. **Integration**: Integrasi dengan sistem lain via API
### Customization
- Ubah warna marker di `assets/css/custom.css`
- Tambah jenis item baru di database `item_types`
- Modifikasi form field di `index.php`
- Tambah validasi di file API
## Lisensi
Aplikasi ini dibuat untuk keperluan pembelajaran dan dapat digunakan secara bebas.
## Kontak
Untuk pertanyaan dan support, silakan hubungi developer.
---
**Catatan**: Pastikan selalu backup database sebelum melakukan perubahan major pada aplikasi.

66
api/detail_redaman.php Normal file
View File

@ -0,0 +1,66 @@
<?php
require_once __DIR__ . '/../config/database.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
//header('Content-Type: application/json');
$database = new Database();
$conn = $database->getConnection();
$id = intval($_GET['id'] ?? 0);
if ($id <= 0) {
echo json_encode(['success'=>false,'message'=>'ID tidak valid']);
exit;
}
$stmt = $conn->prepare("SELECT serial_number FROM ftth_items WHERE id=:id");
$stmt->bindParam(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
echo json_encode(['success'=>false,'message'=>'Data tidak ditemukan']);
exit;
}
$serialNumber = $row['serial_number'];
// Ambil RX Power dan PPPoE IP dari API
$rxPower = null;
$pppoeIP = null;
if ($serialNumber) {
$query = json_encode(["_id" => $serialNumber]);
$url = "http://localhost:7557/devices/?query=" . urlencode($query);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
if ($response) {
$data = json_decode($response, true);
// Ambil RX Power
if (isset($data[0]['VirtualParameters']['RXPower']['_value'])) {
$rxPower = $data[0]['VirtualParameters']['RXPower']['_value'] . " dBm";
}
// Ambil PPPoE IP
if (isset($data[0]['VirtualParameters']['pppoeIP']['_value'])) {
$pppoeIP = $data[0]['VirtualParameters']['pppoeIP']['_value'];
}
}
}
// Output JSON untuk AJAX
echo json_encode([
'success' => true,
'data' => [
'serial_number' => $serialNumber,
'rx_power' => $rxPower ?? 'Tidak tersedia',
'pppoe_ip' => $pppoeIP ?? 'Tidak tersedia'
]
]);

358
api/items.php Normal file
View File

@ -0,0 +1,358 @@
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Requested-With, Authorization');
header('Access-Control-Max-Age: 3600');
// Handle preflight OPTIONS request
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
require_once __DIR__ . '/../config/database.php';
// Function to manually parse multipart form data
function parseMultipartFormData($input, $contentType) {
$data = array();
// Extract boundary from content type
if (preg_match('/boundary=(.+)$/', $contentType, $matches)) {
$boundary = $matches[1];
// Split the input by boundary
$parts = array_slice(explode('--' . $boundary, $input), 1);
foreach ($parts as $part) {
// Skip empty parts and closing boundary
if (trim($part) == '--' || empty(trim($part))) continue;
// Split headers and body
$sections = explode("\r\n\r\n", $part, 2);
if (count($sections) != 2) continue;
$headers = $sections[0];
$body = rtrim($sections[1], "\r\n");
// Extract field name from Content-Disposition header
if (preg_match('/name="([^"]*)"/', $headers, $matches)) {
$fieldName = $matches[1];
$data[$fieldName] = $body;
}
}
}
return $data;
}
$database = new Database();
$db = $database->getConnection();
// Handle method override and multipart data parsing
$method = $_SERVER['REQUEST_METHOD'];
$parsed_data = array();
// Parse multipart form data manually if needed
if (($method === 'PUT' || $method === 'PATCH') && empty($_POST) &&
isset($_SERVER['CONTENT_TYPE']) && strpos($_SERVER['CONTENT_TYPE'], 'multipart/form-data') !== false) {
// Force treat as POST to get parsed form data
$raw_input = file_get_contents('php://input');
$parsed_data = parseMultipartFormData($raw_input, $_SERVER['CONTENT_TYPE']);
// If we found form data, treat this as a method override
if (!empty($parsed_data)) {
if (isset($parsed_data['_method'])) {
$method = strtoupper($parsed_data['_method']);
unset($parsed_data['_method']);
}
// Populate $_POST with parsed data for compatibility
$_POST = $parsed_data;
}
}
// Check for X-HTTP-Method-Override header
if (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
$method = $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'];
}
// Check for _method parameter (Laravel style) - now works with manually parsed data too
if (isset($_POST['_method'])) {
$method = strtoupper($_POST['_method']);
unset($_POST['_method']); // Clean it up
}
$response = array('success' => false, 'message' => '', 'data' => null);
// Log only important requests for production
if ($method === 'PUT' || $method === 'DELETE') {
error_log("FTTH API - " . $method . " request, ID: " . (isset($_POST['id']) ? $_POST['id'] : 'N/A'));
}
try {
switch($method) {
case 'GET':
if (isset($_GET['id'])) {
// Get single item
$query = "SELECT i.*, i.serial_number,
it.name as item_type_name, it.icon, it.color,
tc.color_name as tube_color_name, tc.hex_code,
cc.color_name as core_color_name, cc.hex_code as core_hex_code,
sm.ratio as splitter_main_ratio,
so.ratio as splitter_odp_ratio
FROM ftth_items i
LEFT JOIN item_types it ON i.item_type_id = it.id
LEFT JOIN tube_colors tc ON i.tube_color_id = tc.id
LEFT JOIN tube_colors cc ON i.core_color_id = cc.id
LEFT JOIN splitter_types sm ON i.splitter_main_id = sm.id
LEFT JOIN splitter_types so ON i.splitter_odp_id = so.id
WHERE i.id = :id";
$stmt = $db->prepare($query);
$stmt->bindParam(':id', $_GET['id']);
$stmt->execute();
$item = $stmt->fetch(PDO::FETCH_ASSOC);
if ($item) {
$response['success'] = true;
$response['data'] = $item;
} else {
$response['message'] = 'Item not found';
}
} else {
// Get all items
$query = "SELECT i.*, i.serial_number,
it.name as item_type_name, it.icon, it.color,
tc.color_name as tube_color_name, tc.hex_code,
cc.color_name as core_color_name, cc.hex_code as core_hex_code,
sm.ratio as splitter_main_ratio,
so.ratio as splitter_odp_ratio
FROM ftth_items i
LEFT JOIN item_types it ON i.item_type_id = it.id
LEFT JOIN tube_colors tc ON i.tube_color_id = tc.id
LEFT JOIN tube_colors cc ON i.core_color_id = cc.id
LEFT JOIN splitter_types sm ON i.splitter_main_id = sm.id
LEFT JOIN splitter_types so ON i.splitter_odp_id = so.id
ORDER BY i.created_at DESC";
$stmt = $db->prepare($query);
$stmt->execute();
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
$response['success'] = true;
$response['data'] = $items;
}
break;
case 'POST':
// Create new item
$item_type_id = $_POST['item_type'] ?? null;
$name = $_POST['name'] ?? null;
$description = $_POST['description'] ?? null;
$latitude = $_POST['latitude'] ?? null;
$longitude = $_POST['longitude'] ?? null;
$address = $_POST['address'] ?? null;
$serial_number = $_POST['serial_number'] ?? null; // <--- Tambahan
// Handle foreign key fields - convert empty strings to NULL
$tube_color_id = (!empty($_POST['tube_color_id']) && $_POST['tube_color_id'] !== '0') ? $_POST['tube_color_id'] : null;
$core_used = (!empty($_POST['core_used']) && $_POST['core_used'] !== '0') ? $_POST['core_used'] : null;
$core_color_id = (!empty($_POST['core_color_id']) && $_POST['core_color_id'] !== '0') ? $_POST['core_color_id'] : null;
$item_cable_type = $_POST['item_cable_type'] ?? 'distribution';
$total_core_capacity = $_POST['total_core_capacity'] ?? 24;
$splitter_main_id = (!empty($_POST['splitter_main_id']) && $_POST['splitter_main_id'] !== '0') ? $_POST['splitter_main_id'] : null;
$splitter_odp_id = (!empty($_POST['splitter_odp_id']) && $_POST['splitter_odp_id'] !== '0') ? $_POST['splitter_odp_id'] : null;
$status = $_POST['status'] ?? 'active';
if (!$item_type_id || !$name || !$latitude || !$longitude) {
$response['message'] = 'Required fields missing';
break;
}
$query = "INSERT INTO ftth_items (
item_type_id, name, description, latitude, longitude, address, serial_number,
tube_color_id, core_used, core_color_id, item_cable_type, total_core_capacity,
splitter_main_id, splitter_odp_id, status
) VALUES (
:item_type_id, :name, :description, :latitude, :longitude, :address, :serial_number,
:tube_color_id, :core_used, :core_color_id, :item_cable_type, :total_core_capacity,
:splitter_main_id, :splitter_odp_id, :status
)";
$stmt = $db->prepare($query);
$stmt->bindParam(':item_type_id', $item_type_id);
$stmt->bindParam(':name', $name);
$stmt->bindParam(':description', $description);
$stmt->bindParam(':latitude', $latitude);
$stmt->bindParam(':longitude', $longitude);
$stmt->bindParam(':address', $address);
$stmt->bindParam(':serial_number', $serial_number); // <--- Bind
$stmt->bindParam(':tube_color_id', $tube_color_id);
$stmt->bindParam(':core_used', $core_used);
$stmt->bindParam(':core_color_id', $core_color_id);
$stmt->bindParam(':item_cable_type', $item_cable_type);
$stmt->bindParam(':total_core_capacity', $total_core_capacity);
$stmt->bindParam(':splitter_main_id', $splitter_main_id);
$stmt->bindParam(':splitter_odp_id', $splitter_odp_id);
$stmt->bindParam(':status', $status);
if ($stmt->execute()) {
$item_id = $db->lastInsertId();
// Get the created item with joins
$query = "SELECT i.*, i.serial_number,
it.name as item_type_name, it.icon, it.color,
tc.color_name as tube_color_name, tc.hex_code,
cc.color_name as core_color_name, cc.hex_code as core_hex_code,
sm.ratio as splitter_main_ratio,
so.ratio as splitter_odp_ratio
FROM ftth_items i
LEFT JOIN item_types it ON i.item_type_id = it.id
LEFT JOIN tube_colors tc ON i.tube_color_id = tc.id
LEFT JOIN tube_colors cc ON i.core_color_id = cc.id
LEFT JOIN splitter_types sm ON i.splitter_main_id = sm.id
LEFT JOIN splitter_types so ON i.splitter_odp_id = so.id
WHERE i.id = :id";
$stmt = $db->prepare($query);
$stmt->bindParam(':id', $item_id);
$stmt->execute();
$response['success'] = true;
$response['message'] = 'Item created successfully';
$response['data'] = $stmt->fetch(PDO::FETCH_ASSOC);
} else {
$response['message'] = 'Failed to create item';
}
break;
case 'PUT':
// Update item - now $_POST should be properly populated
$put_data = $_POST;
$id = $put_data['id'] ?? null;
if (!$id) {
$response['message'] = 'ID required for update';
break;
}
// Build dynamic update query
$update_fields = array();
$params = array(':id' => $id);
$allowed_fields = ['item_type', 'name', 'description', 'latitude', 'longitude', 'address', 'serial_number','tube_color_id', 'core_used', 'core_color_id', 'item_cable_type', 'total_core_capacity', 'splitter_main_id', 'splitter_odp_id', 'status'];
foreach ($allowed_fields as $field) {
if (isset($put_data[$field])) {
$db_field = $field === 'item_type' ? 'item_type_id' : $field;
$update_fields[] = "$db_field = :$field";
// Handle empty values for foreign key fields - convert to NULL
$value = $put_data[$field];
if (in_array($field, ['tube_color_id', 'core_color_id', 'splitter_main_id', 'splitter_odp_id']) &&
($value === '' || $value === '0' || $value === 0)) {
$value = null;
}
$params[":$field"] = $value;
}
}
if (empty($update_fields)) {
$response['message'] = 'No fields to update';
break;
}
$query = "UPDATE ftth_items SET " . implode(', ', $update_fields) . " WHERE id = :id";
$stmt = $db->prepare($query);
if ($stmt->execute($params)) {
// Get updated item
$query = "SELECT i.*, i.serial_number,
it.name as item_type_name, it.icon, it.color,
tc.color_name as tube_color_name, tc.hex_code,
cc.color_name as core_color_name, cc.hex_code as core_hex_code,
sm.ratio as splitter_main_ratio,
so.ratio as splitter_odp_ratio
FROM ftth_items i
LEFT JOIN item_types it ON i.item_type_id = it.id
LEFT JOIN tube_colors tc ON i.tube_color_id = tc.id
LEFT JOIN tube_colors cc ON i.core_color_id = cc.id
LEFT JOIN splitter_types sm ON i.splitter_main_id = sm.id
LEFT JOIN splitter_types so ON i.splitter_odp_id = so.id
WHERE i.id = :id";
$stmt = $db->prepare($query);
$stmt->bindParam(':id', $id);
$stmt->execute();
$response['success'] = true;
$response['message'] = 'Item updated successfully';
$response['data'] = $stmt->fetch(PDO::FETCH_ASSOC);
} else {
$response['message'] = 'Failed to update item';
}
break;
case 'DELETE':
// Delete item
$input = file_get_contents("php://input");
$delete_data = array();
// Try to parse JSON first, then form data
$json_data = json_decode($input, true);
if ($json_data) {
$delete_data = $json_data;
} else {
parse_str($input, $delete_data);
}
// Also check for regular POST data (for compatibility)
if (empty($delete_data)) {
$delete_data = $_POST;
}
$id = $delete_data['id'] ?? null;
if (!$id) {
$response['message'] = 'ID required for deletion';
break;
}
// Delete related routes first
$query = "DELETE FROM cable_routes WHERE from_item_id = :id OR to_item_id = :id";
$stmt = $db->prepare($query);
$stmt->bindParam(':id', $id);
$stmt->execute();
// Delete the item
$query = "DELETE FROM ftth_items WHERE id = :id";
$stmt = $db->prepare($query);
$stmt->bindParam(':id', $id);
if ($stmt->execute()) {
$response['success'] = true;
$response['message'] = 'Item deleted successfully';
} else {
$response['message'] = 'Failed to delete item';
}
break;
default:
$response['message'] = 'Method not allowed';
break;
}
} catch (Exception $e) {
$response['message'] = 'Database error: ' . $e->getMessage();
}
echo json_encode($response);
?>

278
api/routes.php Normal file
View File

@ -0,0 +1,278 @@
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Requested-With, Authorization');
header('Access-Control-Max-Age: 3600');
// Handle preflight OPTIONS request
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
require_once '../config/database.php';
$database = new Database();
$db = $database->getConnection();
// Function to parse multipart/form-data for PUT requests
function parseMultipartFormData($input, $contentType) {
$data = array();
if (preg_match('/boundary=(.+)$/', $contentType, $matches)) {
$boundary = $matches[1];
$parts = array_slice(explode('--' . $boundary, $input), 1);
foreach ($parts as $part) {
if (trim($part) == '--' || empty(trim($part))) continue;
$sections = explode("\r\n\r\n", $part, 2);
if (count($sections) != 2) continue;
$headers = $sections[0];
$body = rtrim($sections[1], "\r\n");
if (preg_match('/name="([^"]*)"/', $headers, $matches)) {
$fieldName = $matches[1];
$data[$fieldName] = $body;
}
}
}
return $data;
}
// Handle method override and multipart data parsing
$method = $_SERVER['REQUEST_METHOD'];
if (($method === 'PUT' || $method === 'PATCH') && empty($_POST) &&
isset($_SERVER['CONTENT_TYPE']) && strpos($_SERVER['CONTENT_TYPE'], 'multipart/form-data') !== false) {
$raw_input = file_get_contents('php://input');
$parsed_data = parseMultipartFormData($raw_input, $_SERVER['CONTENT_TYPE']);
if (!empty($parsed_data)) {
if (isset($parsed_data['_method'])) {
$method = strtoupper($parsed_data['_method']);
unset($parsed_data['_method']);
}
$_POST = $parsed_data;
}
}
// Check for X-HTTP-Method-Override header
if (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
$method = $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'];
}
// Check for _method parameter (Laravel style)
if (isset($_POST['_method'])) {
$method = strtoupper($_POST['_method']);
unset($_POST['_method']); // Clean it up
}
$response = array('success' => false, 'message' => '', 'data' => null);
try {
switch($method) {
case 'GET':
if (isset($_GET['id'])) {
// Get single route
$query = "SELECT r.*,
fi.name as from_item_name, fi.latitude as from_lat, fi.longitude as from_lng,
ti.name as to_item_name, ti.latitude as to_lat, ti.longitude as to_lng
FROM cable_routes r
LEFT JOIN ftth_items fi ON r.from_item_id = fi.id
LEFT JOIN ftth_items ti ON r.to_item_id = ti.id
WHERE r.id = :id";
$stmt = $db->prepare($query);
$stmt->bindParam(':id', $_GET['id']);
$stmt->execute();
$route = $stmt->fetch(PDO::FETCH_ASSOC);
if ($route) {
$response['success'] = true;
$response['data'] = $route;
} else {
$response['message'] = 'Route not found';
}
} else {
// Get all routes
$query = "SELECT r.*,
fi.name as from_item_name, fi.latitude as from_lat, fi.longitude as from_lng,
ti.name as to_item_name, ti.latitude as to_lat, ti.longitude as to_lng
FROM cable_routes r
LEFT JOIN ftth_items fi ON r.from_item_id = fi.id
LEFT JOIN ftth_items ti ON r.to_item_id = ti.id
ORDER BY r.created_at DESC";
$stmt = $db->prepare($query);
$stmt->execute();
$routes = $stmt->fetchAll(PDO::FETCH_ASSOC);
$response['success'] = true;
$response['data'] = $routes;
}
break;
case 'POST':
// Create new route
$from_item_id = $_POST['from_item_id'] ?? null;
$to_item_id = $_POST['to_item_id'] ?? null;
$route_coordinates = $_POST['route_coordinates'] ?? null;
$distance = $_POST['distance'] ?? null;
$cable_type = $_POST['cable_type'] ?? 'Fiber Optic';
$core_count = $_POST['core_count'] ?? 24;
$status = $_POST['status'] ?? 'planned';
if (!$from_item_id || !$to_item_id) {
$response['message'] = 'From and To items are required';
break;
}
// Check if route already exists
$query = "SELECT id FROM cable_routes WHERE
(from_item_id = :from_id AND to_item_id = :to_id) OR
(from_item_id = :to_id AND to_item_id = :from_id)";
$stmt = $db->prepare($query);
$stmt->bindParam(':from_id', $from_item_id);
$stmt->bindParam(':to_id', $to_item_id);
$stmt->execute();
if ($stmt->fetch()) {
$response['message'] = 'Route already exists between these items';
break;
}
$query = "INSERT INTO cable_routes (from_item_id, to_item_id, route_coordinates, distance, cable_type, core_count, status)
VALUES (:from_item_id, :to_item_id, :route_coordinates, :distance, :cable_type, :core_count, :status)";
$stmt = $db->prepare($query);
$stmt->bindParam(':from_item_id', $from_item_id);
$stmt->bindParam(':to_item_id', $to_item_id);
$stmt->bindParam(':route_coordinates', $route_coordinates);
$stmt->bindParam(':distance', $distance);
$stmt->bindParam(':cable_type', $cable_type);
$stmt->bindParam(':core_count', $core_count);
$stmt->bindParam(':status', $status);
if ($stmt->execute()) {
$route_id = $db->lastInsertId();
$response['success'] = true;
$response['message'] = 'Route created successfully';
$response['route_id'] = $route_id;
} else {
$response['message'] = 'Failed to create route';
}
break;
case 'PUT':
// Update route - now using $_POST populated by parser
$put_data = $_POST;
// Debug logging
error_log('🔧 Route PUT Request - Method: ' . $method);
error_log('🔧 POST data: ' . print_r($put_data, true));
$id = $put_data['id'] ?? null;
if (!$id) {
error_log('❌ Route update failed: No ID provided');
$response['message'] = 'ID required for update';
break;
}
// Build dynamic update query
$update_fields = array();
$params = array(':id' => $id);
$allowed_fields = ['cable_type', 'core_count', 'status', 'distance', 'route_coordinates'];
foreach ($allowed_fields as $field) {
if (isset($put_data[$field])) {
$update_fields[] = "$field = :$field";
$params[":$field"] = $put_data[$field];
}
}
if (empty($update_fields)) {
$response['message'] = 'No fields to update';
break;
}
$query = "UPDATE cable_routes SET " . implode(', ', $update_fields) . " WHERE id = :id";
$stmt = $db->prepare($query);
if ($stmt->execute($params)) {
$response['success'] = true;
$response['message'] = 'Route updated successfully';
} else {
$response['message'] = 'Failed to update route';
}
break;
case 'DELETE':
$input = file_get_contents("php://input");
$delete_data = array();
// Try to parse JSON first, then form data
$json_data = json_decode($input, true);
if ($json_data) {
$delete_data = $json_data;
} else {
parse_str($input, $delete_data);
}
// Also check for regular POST data (for compatibility)
if (empty($delete_data)) {
$delete_data = $_POST;
}
if (isset($delete_data['id'])) {
// Delete single route
$id = $delete_data['id'];
$query = "DELETE FROM cable_routes WHERE id = :id";
$stmt = $db->prepare($query);
$stmt->bindParam(':id', $id);
if ($stmt->execute()) {
$response['success'] = true;
$response['message'] = 'Route deleted successfully';
} else {
$response['message'] = 'Failed to delete route';
}
} else if (isset($delete_data['item_id'])) {
// Delete all routes connected to an item
$item_id = $delete_data['item_id'];
// Get route IDs first
$query = "SELECT id FROM cable_routes WHERE from_item_id = :item_id OR to_item_id = :item_id";
$stmt = $db->prepare($query);
$stmt->bindParam(':item_id', $item_id);
$stmt->execute();
$route_ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
// Delete routes
$query = "DELETE FROM cable_routes WHERE from_item_id = :item_id OR to_item_id = :item_id";
$stmt = $db->prepare($query);
$stmt->bindParam(':item_id', $item_id);
if ($stmt->execute()) {
$response['success'] = true;
$response['message'] = 'Routes deleted successfully';
$response['deleted_routes'] = $route_ids;
} else {
$response['message'] = 'Failed to delete routes';
}
} else {
$response['message'] = 'ID or item_id required for deletion';
}
break;
default:
$response['message'] = 'Method not allowed';
break;
}
} catch (Exception $e) {
$response['message'] = 'Database error: ' . $e->getMessage();
}
echo json_encode($response);
?>

26
api/splitters.php Normal file
View File

@ -0,0 +1,26 @@
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
require_once __DIR__ . '/../config/database.php';
$database = new Database();
$db = $database->getConnection();
$response = array('success' => false, 'message' => '', 'data' => null);
try {
$query = "SELECT * FROM splitter_types ORDER BY type, ratio";
$stmt = $db->prepare($query);
$stmt->execute();
$splitters = $stmt->fetchAll(PDO::FETCH_ASSOC);
$response['success'] = true;
$response['data'] = $splitters;
} catch (Exception $e) {
$response['message'] = 'Database error: ' . $e->getMessage();
}
echo json_encode($response);
?>

59
api/statistics.php Normal file
View File

@ -0,0 +1,59 @@
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
require_once __DIR__ . '/../config/database.php';
$database = new Database();
$db = $database->getConnection();
$response = array('success' => false, 'message' => '', 'data' => null);
try {
// Get item counts by type
$query = "SELECT it.name as item_type, COUNT(i.id) as count
FROM item_types it
LEFT JOIN ftth_items i ON it.id = i.item_type_id
GROUP BY it.id, it.name
ORDER BY it.id";
$stmt = $db->prepare($query);
$stmt->execute();
$statistics = array();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$key = strtolower(str_replace(' ', '_', $row['item_type']));
$statistics[$key] = (int)$row['count'];
}
// Get total routes
$query = "SELECT COUNT(*) as total_routes FROM cable_routes";
$stmt = $db->prepare($query);
$stmt->execute();
$result = $stmt->fetch(PDO::FETCH_ASSOC);
$statistics['total_routes'] = (int)$result['total_routes'];
// Get route status counts
$query = "SELECT status, COUNT(*) as count FROM cable_routes GROUP BY status";
$stmt = $db->prepare($query);
$stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$statistics['routes_' . $row['status']] = (int)$row['count'];
}
// Calculate total distance
$query = "SELECT SUM(distance) as total_distance FROM cable_routes WHERE distance IS NOT NULL";
$stmt = $db->prepare($query);
$stmt->execute();
$result = $stmt->fetch(PDO::FETCH_ASSOC);
$statistics['total_distance_km'] = round((float)$result['total_distance'] / 1000, 2);
$response['success'] = true;
$response['data'] = $statistics;
} catch (Exception $e) {
$response['message'] = 'Database error: ' . $e->getMessage();
}
echo json_encode($response);
?>

26
api/tube_colors.php Normal file
View File

@ -0,0 +1,26 @@
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
require_once __DIR__ . '/../config/database.php';
$database = new Database();
$db = $database->getConnection();
$response = array('success' => false, 'message' => '', 'data' => null);
try {
$query = "SELECT * FROM tube_colors ORDER BY color_name";
$stmt = $db->prepare($query);
$stmt->execute();
$colors = $stmt->fetchAll(PDO::FETCH_ASSOC);
$response['success'] = true;
$response['data'] = $colors;
} catch (Exception $e) {
$response['message'] = 'Database error: ' . $e->getMessage();
}
echo json_encode($response);
?>

486
assets/css/custom.css Normal file
View File

@ -0,0 +1,486 @@
/* Custom CSS untuk FTTH Planner */
/* Map Styles */
#map {
border-radius: 0;
border: none;
}
.leaflet-popup-content {
margin: 8px 15px;
line-height: 1.4;
}
.leaflet-popup-content h5 {
margin: 0 0 5px 0;
color: #007bff;
}
.popup-info {
font-size: 13px;
}
.popup-info .info-row {
margin-bottom: 5px;
}
.popup-info .info-label {
font-weight: bold;
color: #333;
}
.popup-actions {
margin-top: 10px;
text-align: center;
}
.popup-actions .btn {
margin: 2px;
padding: 5px 10px;
font-size: 12px;
}
/* Custom marker icons */
.custom-marker {
border-radius: 50%;
border: 3px solid white;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
}
.marker-olt {
background-color: #FF6B6B;
}
.marker-tiang {
background-color: #4ECDC4;
}
.marker-odp {
background-color: #45B7D1;
}
.marker-odc {
background-color: #96CEB4;
}
.marker-pelanggan {
background-color: #FFA500;
}
/* Modal styles */
.modal-header {
background-color: #007bff;
color: white;
}
.modal-header .close {
color: white;
opacity: 0.8;
}
.modal-header .close:hover {
opacity: 1;
}
/* Form styles */
.form-group label {
font-weight: 600;
color: #333;
}
.tube-color-option {
display: inline-block;
width: 20px;
height: 20px;
margin-right: 8px;
vertical-align: middle;
border-radius: 3px;
border: 1px solid #ddd;
}
/* Routing styles */
.routing-mode {
cursor: crosshair !important;
}
.route-line {
stroke-width: 4;
stroke-opacity: 0.8;
}
.route-installed {
stroke: #28a745;
}
.route-planned {
stroke: #ffc107;
stroke-dasharray: 10, 5;
}
.route-maintenance {
stroke: #dc3545;
stroke-dasharray: 5, 5;
}
/* Statistics cards */
.small-box {
border-radius: 10px;
}
.small-box .icon {
font-size: 60px;
}
/* Sidebar brand */
.brand-link {
border-bottom: 1px solid #4f5962;
}
.brand-link:hover {
text-decoration: none;
}
/* Loading spinner */
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255,255,255,.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Responsive adjustments */
@media (max-width: 768px) {
#map {
height: 400px !important;
}
.modal-dialog {
margin: 10px;
}
.popup-actions .btn {
display: block;
width: 100%;
margin: 3px 0;
}
}
/* Drag and drop indicators */
.item-dragging {
opacity: 0.7;
transform: scale(1.1);
z-index: 1000;
}
.drop-zone {
border: 2px dashed #007bff;
background-color: rgba(0, 123, 255, 0.1);
}
/* Route drawing mode */
.route-drawing .leaflet-container {
cursor: crosshair;
}
.route-point {
background-color: #ff6b6b;
border: 2px solid white;
border-radius: 50%;
width: 12px;
height: 12px;
}
/* Notifications */
.toast {
min-width: 300px;
}
.toast-header {
background-color: #007bff;
color: white;
}
/* Map controls */
.map-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
}
.map-controls .btn-group-vertical .btn {
margin-bottom: 5px;
}
/* Legend */
.map-legend {
background: white;
padding: 10px;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
position: absolute;
bottom: 10px;
left: 10px;
z-index: 1000;
max-width: 200px;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.legend-icon {
width: 20px;
height: 20px;
margin-right: 8px;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
/* Enhanced Map Controls */
.leaflet-control-zoom {
border: none !important;
border-radius: 5px !important;
box-shadow: 0 2px 10px rgba(0,0,0,0.2) !important;
}
.leaflet-control-zoom a {
background-color: #fff !important;
border: none !important;
color: #333 !important;
font-weight: bold;
transition: all 0.3s ease;
}
.leaflet-control-zoom a:hover {
background-color: #007bff !important;
color: white !important;
transform: scale(1.1);
}
.leaflet-control-custom {
border-radius: 3px !important;
transition: all 0.3s ease;
}
.leaflet-control-custom:hover {
background-color: #007bff !important;
color: white !important;
transform: scale(1.1);
}
.leaflet-control-custom i {
color: #333;
transition: color 0.3s ease;
}
.leaflet-control-custom:hover i {
color: white !important;
}
/* Layer Control Styling */
.leaflet-control-layers {
border-radius: 8px !important;
box-shadow: 0 2px 15px rgba(0,0,0,0.2) !important;
background: white !important;
padding: 8px !important;
}
.leaflet-control-layers-toggle {
background-image: none !important;
color: #333 !important;
font-size: 18px;
}
.leaflet-control-layers-expanded {
padding: 12px !important;
}
.leaflet-control-layers label {
font-weight: 500;
margin: 5px 0;
display: flex;
align-items: center;
}
.leaflet-control-layers input[type="radio"] {
margin-right: 8px;
}
/* Coordinates Control */
.leaflet-control-coords {
font-family: 'Courier New', monospace;
border-radius: 3px;
border: 1px solid #ccc;
box-shadow: 0 1px 5px rgba(0,0,0,0.2);
}
/* Scale Control */
.leaflet-control-scale {
margin-bottom: 40px !important;
}
/* Help Control */
.leaflet-control-help {
border: 1px solid #ccc;
box-shadow: 0 1px 5px rgba(0,0,0,0.2);
}
.leaflet-control-help i {
color: #007bff;
font-size: 16px;
}
/* Fullscreen Control */
.leaflet-control-fullscreen a {
background-color: white !important;
border-radius: 3px;
color: #333 !important;
transition: all 0.3s ease;
}
.leaflet-control-fullscreen a:hover {
background-color: #007bff !important;
color: white !important;
}
/* Map Zoom Buttons in Header */
.map-zoom-controls {
display: inline-flex;
gap: 5px;
margin-left: 10px;
}
.map-zoom-controls .btn {
padding: 4px 8px;
font-size: 12px;
line-height: 1.2;
}
/* Enhanced Map Container */
#map {
border: 2px solid #e3f2fd;
border-radius: 8px;
overflow: hidden;
position: relative;
}
/* Map Loading Indicator */
.map-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(255,255,255,0.9);
padding: 20px;
border-radius: 8px;
z-index: 10000;
display: none;
}
.map-loading.active {
display: block;
}
/* Responsive Map Controls */
@media (max-width: 768px) {
.leaflet-control-layers {
max-width: 200px;
}
.leaflet-control-coords {
font-size: 10px;
padding: 3px;
}
.map-zoom-controls {
display: none; /* Hide header zoom controls on mobile */
}
}
/* Map Tooltips */
.map-tooltip {
background: rgba(0,0,0,0.8);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.map-tooltip:before {
border-top-color: rgba(0,0,0,0.8);
}
/* Item Detail Modal Styles */
.modal-xl {
max-width: 1140px;
}
.color-box {
border-radius: 3px;
vertical-align: middle;
}
.popup-actions {
margin-top: 10px;
text-align: center;
}
.popup-actions .btn {
margin: 2px;
font-size: 11px;
}
.info-row {
margin-bottom: 5px;
font-size: 13px;
}
.info-label {
font-weight: bold;
color: #666;
}
/* Enhanced table styling for detail modal */
.modal-body .table td {
border-top: none;
padding: 8px 12px;
vertical-align: middle;
}
.modal-body .table tr:nth-child(even) {
background-color: #f8f9fa;
}
.modal-body .table td:first-child {
width: 35%;
font-weight: 500;
color: #495057;
}
.modal-body .card {
box-shadow: none;
border: 1px solid #dee2e6;
}
.modal-body .card-header {
border-bottom: 1px solid rgba(255,255,255,0.2);
}
/* Badge improvements */
.badge {
font-size: 0.85em;
padding: 0.4em 0.6em;
}

BIN
assets/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1248
assets/js/app.js Normal file

File diff suppressed because it is too large Load Diff

392
assets/js/kmz-export.js Normal file
View File

@ -0,0 +1,392 @@
// KMZ Export functionality for FTTH Planner
// Main export function
function exportToKMZ() {
showNotification('Menggenerate KMZ file...', 'info');
// Get all items and routes data
Promise.all([
fetchAllItems(),
fetchAllRoutes()
]).then(function(results) {
let items = results[0];
let routes = results[1];
// Validate data
if (!items || items.length === 0) {
showNotification('Tidak ada data item untuk diekspor', 'warning');
return;
}
// Filter items with valid coordinates
let validItems = items.filter(function(item) {
let lat = parseFloat(item.latitude);
let lng = parseFloat(item.longitude);
return !isNaN(lat) && !isNaN(lng);
});
if (validItems.length === 0) {
showNotification('Tidak ada item dengan koordinat yang valid untuk diekspor', 'warning');
return;
}
if (validItems.length < items.length) {
let skipped = items.length - validItems.length;
showNotification(`${skipped} item dilewati karena koordinat tidak valid`, 'warning');
}
// Generate KML content
let kmlContent = generateKML(validItems, routes);
// Create KMZ file and download
createKMZFile(kmlContent);
}).catch(function(error) {
console.error('Error exporting KMZ:', error);
showNotification('Error menggenerate KMZ: ' + error.message, 'error');
});
}
// Fetch all items from API
function fetchAllItems() {
return new Promise(function(resolve, reject) {
$.ajax({
url: 'api/items.php',
method: 'GET',
success: function(response) {
if (response.success) {
resolve(response.data);
} else {
reject(new Error(response.message || 'Failed to fetch items'));
}
},
error: function(xhr, status, error) {
reject(new Error('API error: ' + error));
}
});
});
}
// Fetch all routes from API
function fetchAllRoutes() {
return new Promise(function(resolve, reject) {
$.ajax({
url: 'api/routes.php',
method: 'GET',
success: function(response) {
if (response.success) {
resolve(response.data);
} else {
reject(new Error(response.message || 'Failed to fetch routes'));
}
},
error: function(xhr, status, error) {
reject(new Error('API error: ' + error));
}
});
});
}
// Generate KML content
function generateKML(items, routes) {
let kml = `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>FTTH Planner Export</name>
<description>Export data infrastruktur FTTH dari FTTH Planner</description>
${generateStyles()}
${generateItemPlacemarks(items)}
${generateRoutePlacemarks(routes)}
</Document>
</kml>`;
return kml;
}
// Generate KML styles for different item types
function generateStyles() {
return `
<!-- Styles for OLT -->
<Style id="olt-style">
<IconStyle>
<Icon>
<href>http://maps.google.com/mapfiles/kml/paddle/red-circle.png</href>
</Icon>
<scale>1.2</scale>
</IconStyle>
<LabelStyle>
<color>ffffffff</color>
<scale>0.8</scale>
</LabelStyle>
</Style>
<!-- Styles for Tiang Tumpu -->
<Style id="tiang-style">
<IconStyle>
<Icon>
<href>http://maps.google.com/mapfiles/kml/paddle/grn-circle.png</href>
</Icon>
<scale>1.0</scale>
</IconStyle>
<LabelStyle>
<color>ffffffff</color>
<scale>0.8</scale>
</LabelStyle>
</Style>
<!-- Styles for ODP -->
<Style id="odp-style">
<IconStyle>
<Icon>
<href>http://maps.google.com/mapfiles/kml/paddle/blu-circle.png</href>
</Icon>
<scale>1.0</scale>
</IconStyle>
<LabelStyle>
<color>ffffffff</color>
<scale>0.8</scale>
</LabelStyle>
</Style>
<!-- Styles for ODC -->
<Style id="odc-style">
<IconStyle>
<Icon>
<href>http://maps.google.com/mapfiles/kml/paddle/grn-square.png</href>
</Icon>
<scale>1.0</scale>
</IconStyle>
<LabelStyle>
<color>ffffffff</color>
<scale>0.8</scale>
</LabelStyle>
</Style>
<!-- Styles for Pelanggan -->
<Style id="pelanggan-style">
<IconStyle>
<Icon>
<href>http://maps.google.com/mapfiles/kml/paddle/orange-circle.png</href>
</Icon>
<scale>0.8</scale>
</IconStyle>
<LabelStyle>
<color>ffffffff</color>
<scale>0.8</scale>
</LabelStyle>
</Style>
<!-- Styles for Routes -->
<Style id="route-planned">
<LineStyle>
<color>ff00ffff</color>
<width>3</width>
</LineStyle>
</Style>
<Style id="route-installed">
<LineStyle>
<color>ff00ff00</color>
<width>4</width>
</LineStyle>
</Style>
<Style id="route-maintenance">
<LineStyle>
<color>ff0000ff</color>
<width>3</width>
</LineStyle>
</Style>
`;
}
// Generate placemarks for items
function generateItemPlacemarks(items) {
let placemarks = '';
items.forEach(function(item) {
// Validate coordinates
let lat = parseFloat(item.latitude);
let lng = parseFloat(item.longitude);
if (isNaN(lat) || isNaN(lng)) {
console.warn('Invalid coordinates for item:', item.name, 'lat:', item.latitude, 'lng:', item.longitude);
return; // Skip this item
}
let styleId = getStyleId(item.item_type_name);
let description = generateItemDescription(item);
placemarks += `
<Placemark>
<name>${escapeXML(item.name)}</name>
<description><![CDATA[${description}]]></description>
<styleUrl>#${styleId}</styleUrl>
<Point>
<coordinates>${lng},${lat},0</coordinates>
</Point>
</Placemark>`;
});
return placemarks;
}
// Generate placemarks for routes
function generateRoutePlacemarks(routes) {
let placemarks = '';
routes.forEach(function(route) {
if (route.route_coordinates) {
let coordinates = '';
try {
let coordArray = JSON.parse(route.route_coordinates);
coordinates = coordArray.map(coord => `${coord.lng || coord[1]},${coord.lat || coord[0]},0`).join(' ');
} catch (e) {
console.warn('Invalid route coordinates for route', route.id);
return;
}
let styleId = 'route-' + route.status;
let description = generateRouteDescription(route);
placemarks += `
<Placemark>
<name>Route: ${escapeXML(route.from_item_name)} ${escapeXML(route.to_item_name)}</name>
<description><![CDATA[${description}]]></description>
<styleUrl>#${styleId}</styleUrl>
<LineString>
<tessellate>1</tessellate>
<coordinates>${coordinates}</coordinates>
</LineString>
</Placemark>`;
}
});
return placemarks;
}
// Get style ID based on item type
function getStyleId(itemType) {
switch(itemType) {
case 'OLT': return 'olt-style';
case 'Tiang Tumpu': return 'tiang-style';
case 'ODP': return 'odp-style';
case 'ODC': return 'odc-style';
case 'Pelanggan': return 'pelanggan-style';
default: return 'odp-style';
}
}
// Generate item description HTML
function generateItemDescription(item) {
let description = `
<table border="1" cellpadding="5">
<tr><td><b>Jenis:</b></td><td>${item.item_type_name}</td></tr>
<tr><td><b>Nama:</b></td><td>${escapeXML(item.name)}</td></tr>`;
if (item.description) {
description += `<tr><td><b>Deskripsi:</b></td><td>${escapeXML(item.description)}</td></tr>`;
}
if (item.address) {
description += `<tr><td><b>Alamat:</b></td><td>${escapeXML(item.address)}</td></tr>`;
}
// Handle coordinates safely
let lat = parseFloat(item.latitude);
let lng = parseFloat(item.longitude);
let coordText = (isNaN(lat) || isNaN(lng)) ? 'Koordinat tidak valid' : `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
description += `<tr><td><b>Koordinat:</b></td><td>${coordText}</td></tr>`;
if (item.tube_color_name) {
description += `<tr><td><b>Warna Tube:</b></td><td>${item.tube_color_name}</td></tr>`;
}
if (item.core_used) {
description += `<tr><td><b>Core Digunakan:</b></td><td>${item.core_used}</td></tr>`;
}
if (item.splitter_main_ratio) {
description += `<tr><td><b>Splitter Utama:</b></td><td>${item.splitter_main_ratio}</td></tr>`;
}
if (item.splitter_odp_ratio) {
description += `<tr><td><b>Splitter ODP:</b></td><td>${item.splitter_odp_ratio}</td></tr>`;
}
description += `<tr><td><b>Status:</b></td><td>${getStatusText(item.status)}</td></tr>`;
description += `</table>`;
return description;
}
// Generate route description HTML
function generateRouteDescription(route) {
let distance = route.distance ? (route.distance / 1000).toFixed(2) + ' km' : 'Unknown';
return `
<table border="1" cellpadding="5">
<tr><td><b>Dari:</b></td><td>${escapeXML(route.from_item_name || 'Unknown')}</td></tr>
<tr><td><b>Ke:</b></td><td>${escapeXML(route.to_item_name || 'Unknown')}</td></tr>
<tr><td><b>Jarak:</b></td><td>${distance}</td></tr>
<tr><td><b>Tipe Kabel:</b></td><td>${escapeXML(route.cable_type || 'Fiber Optic')}</td></tr>
<tr><td><b>Jumlah Core:</b></td><td>${route.core_count || 24}</td></tr>
<tr><td><b>Status:</b></td><td>${getStatusText(route.status)}</td></tr>
</table>
`;
}
// Escape XML special characters
function escapeXML(text) {
if (!text) return '';
return text.toString()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Get status text in Indonesian
function getStatusText(status) {
switch(status) {
case 'active': return 'Aktif';
case 'inactive': return 'Tidak Aktif';
case 'maintenance': return 'Maintenance';
case 'planned': return 'Perencanaan';
case 'installed': return 'Terpasang';
default: return status || 'Unknown';
}
}
// Create KMZ file and trigger download
function createKMZFile(kmlContent) {
try {
// Create ZIP file containing the KML
let zip = new JSZip();
zip.file("doc.kml", kmlContent);
// Generate KMZ file
zip.generateAsync({type:"blob"}).then(function(content) {
// Create filename with timestamp
let timestamp = new Date().toISOString().slice(0,19).replace(/:/g,'-');
let filename = `FTTH_Planner_Export_${timestamp}.kmz`;
// Save file
saveAs(content, filename);
showNotification(`KMZ file berhasil diunduh: ${filename}`, 'success');
}).catch(function(error) {
console.error('Error creating KMZ:', error);
showNotification('Error membuat file KMZ: ' + error.message, 'error');
});
} catch (error) {
console.error('Error in createKMZFile:', error);
showNotification('Error membuat KMZ file: ' + error.message, 'error');
}
}
// Add export button to global scope
window.exportToKMZ = exportToKMZ;

972
assets/js/map.js Normal file
View File

@ -0,0 +1,972 @@
// Map.js - FTTH Planner Map Functionality
let map;
let markers = {};
let routes = {};
let isRoutingMode = false;
let routingFromItem = null;
let currentRoutes = [];
// Initialize map
function initMap() {
// Create map with enhanced options
map = L.map('map', {
center: [-7.70298100, 114.01477000], // situbondo, Indonesia
zoom: 11,
minZoom: 5,
maxZoom: 20,
zoomControl: false, // We'll add custom zoom control
fullscreenControl: true,
fullscreenControlOptions: {
position: 'topleft'
}
});
// Buat custom search control
// Buat kontrol pencarian//tombol cari lokasi
var searchMarker = null; // Simpan marker yang sedang aktif
var SearchControl = L.Control.extend({
onAdd: function(map) {
var container = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-custom');
// Styling container lebih kecil
container.style.display = 'flex';
container.style.alignItems = 'center';
container.style.padding = '4px 8px';
container.style.gap = '6px';
container.style.minWidth = '160px';
container.style.width = '28vw';
container.style.maxWidth = '280px';
container.style.boxSizing = 'border-box';
// Buat icon pencarian
var icon = L.DomUtil.create('span', '', container);
icon.innerHTML = '🔍';
icon.style.fontSize = '14px';
// Buat input
var input = L.DomUtil.create('input', '', container);
input.type = "text";
input.placeholder = "Cari lokasi...";
input.style.flex = "1";
input.style.padding = "4px";
input.style.border = "none";
input.style.outline = "none";
input.style.fontSize = "13px";
// Tombol X untuk clear
var clearBtn = L.DomUtil.create('span', '', container);
clearBtn.innerHTML = '✖';
clearBtn.style.cursor = 'pointer';
clearBtn.style.fontSize = '12px';
clearBtn.style.display = 'none';
clearBtn.addEventListener('click', function() {
if (searchMarker) {
map.removeLayer(searchMarker);
searchMarker = null;
}
input.value = '';
clearBtn.style.display = 'none';
});
// Event Enter untuk pencarian
L.DomEvent.addListener(input, 'keydown', function(e) {
if (e.key === 'Enter') {
fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(input.value)}`)
.then(res => res.json())
.then(data => {
if (data.length > 0) {
var lat = parseFloat(data[0].lat);
var lon = parseFloat(data[0].lon);
map.setView([lat, lon], 15);
if (searchMarker) {
map.removeLayer(searchMarker);
}
searchMarker = L.marker([lat, lon]).addTo(map).bindPopup(input.value).openPopup();
clearBtn.style.display = 'inline';
}
});
}
});
L.DomEvent.disableClickPropagation(container);
return container;
}
});
// Tambahkan ke peta
map.addControl(new SearchControl());
// Pindahkan ke container utama map
var searchEl = document.querySelector('.leaflet-control-custom');
document.querySelector('.leaflet-container').appendChild(searchEl);
// Posisi kiri atas dengan jarak dari fullscreen
searchEl.style.position = 'absolute';
searchEl.style.top = '10px';
searchEl.style.left = '50px'; // kasih jarak 50px biar gak nabrak tombol fullscreen
searchEl.style.background = 'white';
searchEl.style.borderRadius = '4px';
searchEl.style.boxShadow = '0 2px 6px rgba(0,0,0,0.3)';
searchEl.style.zIndex = 1000;
// Define multiple tile layers
const tileLayers = {
"OpenStreetMap": L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}),
"CartoDB Positron": L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
maxZoom: 20
}),
"CartoDB Dark": L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
maxZoom: 20
}),
"Satellite": L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
maxZoom: 20
}),
"Terrain": L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
attribution: 'Map data: &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, <a href="http://viewfinderpanoramas.org">SRTM</a> | Map style: &copy; <a href="https://opentopomap.org">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)',
maxZoom: 17
}),
"Google Hybrid": L.tileLayer('https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}', {
attribution: '&copy; Google',
maxZoom: 20
})
};
// Add default layer (OpenStreetMap)
tileLayers["OpenStreetMap"].addTo(map);
// Add layer control
L.control.layers(tileLayers, null, {
position: 'topright',
collapsed: false
}).addTo(map);
// Add custom zoom control with home button
const zoomControl = L.control.zoom({
position: 'topleft'
}).addTo(map);
// Add home button to zoom control
const homeControl = L.Control.extend({
options: {
position: 'topleft'
},
onAdd: function(map) {
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-custom');
container.style.backgroundColor = 'white';
container.style.backgroundImage = 'none';
container.style.width = '26px';
container.style.height = '26px';
container.style.cursor = 'pointer';
container.innerHTML = '<i class="fas fa-home" style="font-size: 14px; line-height: 26px; text-align: center; width: 26px; display: block;"></i>';
container.title = 'Zoom to Indonesia';
container.onclick = function() {
map.setView([-2.5, 118], 5); // Indonesia overview
};
return container;
}
});
new homeControl().addTo(map);
// Add scale control
L.control.scale({
position: 'bottomright',
metric: true,
imperial: false
}).addTo(map);
// Add coordinates display
const coordsControl = L.control({position: 'bottomleft'});
coordsControl.onAdd = function(map) {
this._div = L.DomUtil.create('div', 'leaflet-control-coords');
this._div.style.background = 'rgba(255,255,255,0.8)';
this._div.style.padding = '5px';
this._div.style.margin = '0';
this._div.style.fontSize = '11px';
this._div.innerHTML = 'Move mouse over map';
return this._div;
};
coordsControl.update = function(lat, lng) {
this._div.innerHTML = `Lat: ${lat.toFixed(6)}, Lng: ${lng.toFixed(6)}`;
};
coordsControl.addTo(map);
// Update coordinates on mouse move
map.on('mousemove', function(e) {
coordsControl.update(e.latlng.lat, e.latlng.lng);
});
// Enhanced zoom behavior
map.on('zoomend', function() {
const zoom = map.getZoom();
if (zoom < 10) {
// Hide detailed markers at low zoom
Object.values(markers).forEach(marker => {
if (marker._icon) {
marker._icon.style.opacity = '0.7';
}
});
} else {
// Show detailed markers at high zoom
Object.values(markers).forEach(marker => {
if (marker._icon) {
marker._icon.style.opacity = '1';
}
});
}
});
// Add map click event for adding new items
map.on('click', function(e) {
if (!isRoutingMode) {
showAddItemModal(e.latlng.lat, e.latlng.lng);
}
});
// Load existing items
loadItems();
loadRoutes();
// Add legend
addMapLegend();
console.log('🗺️ Enhanced map initialized with multiple tile layers and zoom controls');
// Add loading indicator
const mapContainer = document.getElementById('map');
const loadingDiv = document.createElement('div');
loadingDiv.className = 'map-loading';
loadingDiv.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading map...';
mapContainer.appendChild(loadingDiv);
// Hide loading indicator after tiles load
map.on('tilesloaded', function() {
if (loadingDiv.parentNode) {
loadingDiv.parentNode.removeChild(loadingDiv);
}
});
}
// Create custom marker icon
// Objek global untuk menyimpan marker berdasarkan SN
// Fungsi buat custom icon
function createCustomIcon(itemType, color) {
let iconClass = 'fas fa-circle';
switch(itemType) {
case 'OLT':
case '1':
iconClass = 'fas fa-server';
break;
case 'Tiang Tumpu':
case '2':
iconClass = 'fas fa-tower-broadcast';
break;
case 'ODP':
case '3':
iconClass = 'fas fa-project-diagram';
break;
case 'ODC':
case '4':
iconClass = 'fas fa-network-wired';
break;
case 'Pelanggan':
case '5':
iconClass = 'fas fa-home';
break;
}
// Default RX Power dulu
let rxPower = "Memuat...";
return L.divIcon({
className: 'custom-div-icon',
html: `
<div class="custom-marker marker-${itemType.toLowerCase().replace(' ', '')}"
style="background-color: ${color}; width: 30px; height: 30px;
border-radius: 50%; display: flex; flex-direction: column;
align-items: center; justify-content: flex-start; border: 3px solid white;
box-shadow: 0 2px 5px rgba(255, 255, 255, 0); position: relative;">
<div class="rx-power-box"
style="width: 40px; height: 16px; margin-bottom: 4px;
border: 1px solid white; background-color: rgba(0, 0, 0, 1);
color: white; font-size: 10px; text-align: center;
position: absolute; top: -18px; left: 50%; transform: translateX(-50%);">
${rxPower}
</div>
<i class="${iconClass}" style="color: white; font-size: 14px;"></i>
</div>
`,
iconSize: [40, 50],
iconAnchor: [20, 40],
popupAnchor: [0, -25]
});
}
// Fungsi update RX Power untuk marker tertentu
// map.js//modifan saya
// pastikan variabel sudah benar
// update RX Power setelah marker dirender
// Create popup content for item
function createPopupContent(item) {
let tubeColorName = item.tube_color_name || 'Tidak ada';
let splitterMain = item.splitter_main_ratio || 'Tidak ada';
let splitterOdp = item.splitter_odp_ratio || 'Tidak ada';
let serialNumber = item.serial_number ? item.serial_number.trim() : '';
let html = `
<div>
<h5><i class="${getItemIcon(item.item_type_name)}"></i> ${item.name}</h5>
<div class="popup-info">
<div class="info-row"><span class="info-label">Jenis:</span> ${item.item_type_name}</div>
${item.description ? `<div class="info-row"><span class="info-label">Deskripsi:</span> ${item.description}</div>` : ''}
${item.address ? `<div class="info-row"><span class="info-label">Alamat:</span> ${item.address}</div>` : ''}
<div class="info-row"><span class="info-label">Warna Tube:</span> ${tubeColorName}</div>
${item.core_used ? `<div class="info-row"><span class="info-label">Core Digunakan:</span> ${item.core_used}</div>` : ''}
<div class="info-row"><span class="info-label">Splitter Utama:</span> ${splitterMain}</div>
<div class="info-row"><span class="info-label">Splitter ODP:</span> ${splitterOdp}</div>
<div class="info-row"><span class="info-label">Status:</span>
<span class="badge badge-${getStatusBadgeClass(item.status)}">${getStatusText(item.status)}</span>
</div>
<div class="popup-actions">
<button class="btn btn-info btn-sm" onclick="loadRedamanDetail(${item.id})"> Detail Redaman</button>
<button class="btn btn-info btn-sm" onclick="showItemDetail(${item.id})"><i class="fas fa-info-circle"></i> Detail</button>
<button class="btn btn-primary btn-sm" onclick="editItem(${item.id})"><i class="fas fa-edit"></i> Edit</button>
<button class="btn btn-success btn-sm" onclick="startRouting(${item.id})"><i class="fas fa-route"></i> Route</button>
<button class="btn btn-danger btn-sm" onclick="deleteItem(${item.id})"><i class="fas fa-trash"></i> Hapus</button>
</div>
</div>
`;
// Ambil RX Power setelah popup ada di DOM
// Lebih baik menggunakan requestAnimationFrame untuk memastikan DOM sudah diupdate
return html;
}
// Get item icon based on type (moved to bottom for global export)
// Get status badge class
function getStatusBadgeClass(status) {
switch(status) {
case 'active': return 'success';
case 'inactive': return 'secondary';
case 'maintenance': return 'warning';
default: return 'primary';
}
}
// Get status text
function getStatusText(status) {
switch(status) {
case 'active': return 'Aktif';
case 'inactive': return 'Tidak Aktif';
case 'maintenance': return 'Maintenance';
default: return status;
}
}
// Load items from database
function loadItems() {
$.ajax({
url: 'api/items.php',
method: 'GET',
success: function(response) {
if (response.success) {
response.data.forEach(function(item) {
addMarkerToMap(item);
});
updateStatistics();
}
},
error: function() {
showNotification('Error loading items', 'error');
}
});
}
// Add marker to map
function addMarkerToMap(item) {
let itemTypeColors = {
'OLT': '#FF6B6B',
'Tiang Tumpu': '#4ECDC4',
'ODP': '#45B7D1',
'ODC': '#96CEB4',
'Pelanggan': '#FFA500'
};
let color = itemTypeColors[item.item_type_name] || '#999';
let icon = createCustomIcon(item.item_type_name, color);
let marker = L.marker([item.latitude, item.longitude], {
icon: icon,
draggable: true,
itemType: item.item_type_name,
itemId: item.id,
itemData: item
}).addTo(map);
marker.bindPopup(createPopupContent(item));
// Add drag event
marker.on('dragend', function(e) {
let newPos = e.target.getLatLng();
updateItemPosition(item.id, newPos.lat, newPos.lng);
});
// Add click event for routing mode
marker.on('click', function(e) {
if (isRoutingMode) {
handleRoutingClick(item);
e.originalEvent.stopPropagation();
}
});
markers[item.id] = marker;
}
// Update item position
function updateItemPosition(itemId, lat, lng) {
// Use FormData with method override for consistency
let formData = new FormData();
formData.append('_method', 'PUT');
formData.append('id', itemId);
formData.append('latitude', lat);
formData.append('longitude', lng);
$.ajax({
url: 'api/items.php',
method: 'POST',
data: formData,
processData: false,
contentType: false,
dataType: 'json',
success: function(response) {
if (response && response.success) {
showNotification('Posisi item berhasil dipindahkan', 'success');
} else {
showNotification(response?.message || 'Error updating position', 'error');
}
},
error: function(xhr, status, error) {
console.error('Position update error:', error, xhr.responseText);
showNotification('Error updating position: ' + error, 'error');
}
});
}
// Start routing mode
function startRouting(itemId) {
isRoutingMode = true;
routingFromItem = itemId;
map.getContainer().style.cursor = 'crosshair';
showNotification('Pilih item tujuan untuk membuat route', 'info');
}
// Handle routing click
function handleRoutingClick(toItem) {
if (routingFromItem && routingFromItem !== toItem.id) {
createRoute(routingFromItem, toItem.id);
exitRoutingMode();
}
}
// Exit routing mode
function exitRoutingMode() {
isRoutingMode = false;
routingFromItem = null;
map.getContainer().style.cursor = '';
}
// Create route between two items
function createRoute(fromItemId, toItemId) {
let fromMarker = markers[fromItemId];
let toMarker = markers[toItemId];
if (!fromMarker || !toMarker) {
showNotification('Marker tidak ditemukan', 'error');
return;
}
let fromPos = fromMarker.getLatLng();
let toPos = toMarker.getLatLng();
console.log('Creating route from', fromPos, 'to', toPos);
// Check if Leaflet Routing Machine is available
if (typeof L.Routing === 'undefined') {
console.log('Leaflet Routing Machine not available, creating simple line');
// Create simple straight line if routing machine not available
createSimpleRoute(fromItemId, toItemId, fromPos, toPos);
return;
}
try {
// Use routing machine to create route following roads
let routing = L.Routing.control({
waypoints: [fromPos, toPos],
routeWhileDragging: false,
show: false,
createMarker: function() { return null; }, // Don't create default markers
addWaypoints: false,
draggableWaypoints: false,
fitSelectedRoutes: false
});
routing.on('routesfound', function(e) {
console.log('Route found:', e.routes[0]);
let route = e.routes[0];
let coordinates = route.coordinates;
// Save route to database
$.ajax({
url: 'api/routes.php',
method: 'POST',
data: {
from_item_id: fromItemId,
to_item_id: toItemId,
route_coordinates: JSON.stringify(coordinates),
distance: route.summary.totalDistance,
cable_type: 'Fiber Optic',
core_count: 24,
status: 'planned'
},
success: function(response) {
console.log('Route save response:', response);
if (response.success) {
// Add route line to map
let routeLine = L.polyline(coordinates, {
color: '#ffc107',
weight: 4,
opacity: 0.8,
dashArray: '10, 5'
}).addTo(map);
// Add popup to route
routeLine.bindPopup(`
<div>
<h6>Route Kabel</h6>
<p><strong>Jarak:</strong> ${(route.summary.totalDistance / 1000).toFixed(2)} km</p>
<p><strong>Tipe Kabel:</strong> Fiber Optic</p>
<p><strong>Jumlah Core:</strong> 24</p>
<p><strong>Status:</strong> Perencanaan</p>
</div>
`);
routes[response.route_id] = routeLine;
showNotification('Route berhasil dibuat', 'success');
// Remove routing control
map.removeControl(routing);
} else {
showNotification(response.message || 'Gagal menyimpan route', 'error');
}
},
error: function(xhr, status, error) {
console.error('Error saving route:', error);
showNotification('Error menyimpan route: ' + error, 'error');
}
});
});
routing.on('routingerror', function(e) {
console.error('Routing error:', e.error);
showNotification('Error routing: ' + e.error.message, 'error');
// Fallback to simple line
createSimpleRoute(fromItemId, toItemId, fromPos, toPos);
});
routing.addTo(map);
} catch (error) {
console.error('Error creating route:', error);
showNotification('Error creating route, using simple line', 'warning');
createSimpleRoute(fromItemId, toItemId, fromPos, toPos);
}
}
// Create simple straight line route (fallback)
function createSimpleRoute(fromItemId, toItemId, fromPos, toPos) {
let coordinates = [[fromPos.lat, fromPos.lng], [toPos.lat, toPos.lng]];
let distance = fromPos.distanceTo(toPos);
$.ajax({
url: 'api/routes.php',
method: 'POST',
data: {
from_item_id: fromItemId,
to_item_id: toItemId,
route_coordinates: JSON.stringify(coordinates),
distance: distance,
cable_type: 'Fiber Optic',
core_count: 24,
status: 'planned'
},
success: function(response) {
if (response.success) {
// Add route line to map
let routeLine = L.polyline(coordinates, {
color: '#ffc107',
weight: 4,
opacity: 0.8,
dashArray: '10, 5'
}).addTo(map);
routeLine.bindPopup(`
<div>
<h6>Route Kabel (Direct)</h6>
<p><strong>Jarak:</strong> ${(distance / 1000).toFixed(2)} km</p>
<p><strong>Tipe Kabel:</strong> Fiber Optic</p>
<p><strong>Jumlah Core:</strong> 24</p>
<p><strong>Status:</strong> Perencanaan</p>
</div>
`);
routes[response.route_id] = routeLine;
showNotification('Route sederhana berhasil dibuat', 'success');
}
},
error: function() {
showNotification('Error menyimpan route', 'error');
}
});
}
// Load routes from database
function loadRoutes() {
$.ajax({
url: 'api/routes.php',
method: 'GET',
success: function(response) {
if (response.success) {
response.data.forEach(function(route) {
if (route.route_coordinates) {
let coordinates = JSON.parse(route.route_coordinates);
let color = getRouteColor(route.status);
let dashArray = route.status === 'installed' ? null : '10, 5';
let routeLine = L.polyline(coordinates, {
color: color,
weight: 4,
opacity: 0.8,
dashArray: dashArray
}).addTo(map);
routeLine.bindPopup(`
<div>
<h6>Route Kabel</h6>
<p><strong>Jarak:</strong> ${(route.distance / 1000).toFixed(2)} km</p>
<p><strong>Tipe Kabel:</strong> ${route.cable_type}</p>
<p><strong>Jumlah Core:</strong> ${route.core_count}</p>
<p><strong>Status:</strong> ${getStatusText(route.status)}</p>
</div>
`);
routes[route.id] = routeLine;
}
});
}
}
});
}
// Get route color based on status
function getRouteColor(status) {
switch(status) {
case 'installed': return '#28a745';
case 'planned': return '#ffc107';
case 'maintenance': return '#dc3545';
default: return '#6c757d';
}
}
// Add map legend
function addMapLegend() {
let legend = L.control({position: 'bottomleft'});
legend.onAdd = function(map) {
let div = L.DomUtil.create('div', 'map-legend');
div.innerHTML = `
<h6 style="margin-bottom: 10px; font-weight: bold;">Legend</h6>
<div class="legend-item">
<div class="legend-icon" style="background-color: #FF6B6B;"><i class="fas fa-server" style="color: white; font-size: 10px;"></i></div>
<span>OLT</span>
</div>
<div class="legend-item">
<div class="legend-icon" style="background-color: #4ECDC4;"><i class="fas fa-tower-broadcast" style="color: white; font-size: 10px;"></i></div>
<span>Tiang Tumpu</span>
</div>
<div class="legend-item">
<div class="legend-icon" style="background-color: #45B7D1;"><i class="fas fa-project-diagram" style="color: white; font-size: 10px;"></i></div>
<span>ODP</span>
</div>
<div class="legend-item">
<div class="legend-icon" style="background-color: #96CEB4;"><i class="fas fa-network-wired" style="color: white; font-size: 10px;"></i></div>
<span>ODC</span>
</div>
<div class="legend-item">
<div class="legend-icon" style="background-color: #FFA500;"><i class="fas fa-home" style="color: white; font-size: 10px;"></i></div>
<span>Pelanggan</span>
</div>
<hr style="margin: 10px 0;">
<div style="font-size: 12px;">
<div><span style="border-bottom: 3px solid #28a745; padding-bottom: 1px;"></span> Terpasang</div>
<div><span style="border-bottom: 3px dashed #ffc107; padding-bottom: 1px;"></span> Perencanaan</div>
<div><span style="border-bottom: 3px dashed #dc3545; padding-bottom: 1px;"></span> Maintenance</div>
</div>
`;
return div;
};
legend.addTo(map);
}
//buatan saya
// Update statistics
function updateStatistics() {
$.ajax({
url: 'api/statistics.php',
method: 'GET',
success: function(response) {
if (response.success) {
$('#stat-olt').text(response.data.olt || 0);
$('#stat-tiang').text(response.data.tiang_tumpu || 0);
$('#stat-odp').text(response.data.odp || 0);
$('#stat-odc').text(response.data.odc || 0);
$('#stat-pelanggan').text(response.data.pelanggan || 0);
$('#stat-routes').text(response.data.total_routes || 0);
}
}
});
}
// Show notification
function showNotification(message, type) {
let alertClass = 'alert-info';
switch(type) {
case 'success': alertClass = 'alert-success'; break;
case 'error': alertClass = 'alert-danger'; break;
case 'warning': alertClass = 'alert-warning'; break;
}
let notification = `
<div class="alert ${alertClass} alert-dismissible fade show" role="alert" style="position: fixed; top: 20px; right: 20px; z-index: 9999; min-width: 300px;">
${message}
<button type="button" class="close" data-dismiss="alert">
<span>&times;</span>
</button>
</div>
`;
$('body').append(notification);
setTimeout(function() {
$('.alert').fadeOut();
}, 5000);
}
// Show routing mode
function showRoutingMode() {
if (isRoutingMode) {
exitRoutingMode();
showNotification('Mode routing dinonaktifkan', 'info');
} else {
isRoutingMode = true;
map.getContainer().style.cursor = 'crosshair';
showNotification('Mode routing aktif. Klik dua item untuk membuat route.', 'info');
}
}
// Zoom to specific bounds
function zoomToItems() {
if (Object.keys(markers).length > 0) {
const group = new L.featureGroup(Object.values(markers));
map.fitBounds(group.getBounds().pad(0.1));
} else {
showNotification('Tidak ada item untuk di-zoom', 'warning');
}
}
// Zoom to specific item type
function zoomToItemType(itemType) {
const filteredMarkers = Object.values(markers).filter(marker => {
return marker.options && marker.options.itemType === itemType;
});
if (filteredMarkers.length > 0) {
const group = new L.featureGroup(filteredMarkers);
map.fitBounds(group.getBounds().pad(0.1));
// Highlight markers of this type temporarily
filteredMarkers.forEach(marker => {
if (marker._icon) {
marker._icon.style.transform += ' scale(1.3)';
marker._icon.style.zIndex = '1000';
setTimeout(() => {
marker._icon.style.transform = marker._icon.style.transform.replace(' scale(1.3)', '');
marker._icon.style.zIndex = '';
}, 2000);
}
});
showNotification(`Menampilkan ${filteredMarkers.length} ${itemType}`, 'success');
} else {
showNotification(`Tidak ada ${itemType} ditemukan`, 'info');
}
}
// Enhanced locate user function
function locateUser() {
if (navigator.geolocation) {
map.locate({
setView: true,
maxZoom: 16,
enableHighAccuracy: true,
timeout: 10000
});
map.on('locationfound', function(e) {
L.circle(e.latlng, e.accuracy).addTo(map)
.bindPopup('Anda berada di sekitar area ini').openPopup();
showNotification('Lokasi berhasil ditemukan', 'success');
});
map.on('locationerror', function(e) {
showNotification('Gagal menemukan lokasi: ' + e.message, 'error');
});
} else {
showNotification('Geolocation tidak didukung browser ini', 'error');
}
}
// Add keyboard shortcuts for zoom
function addKeyboardShortcuts() {
document.addEventListener('keydown', function(e) {
if (e.target.tagName.toLowerCase() === 'input' || e.target.tagName.toLowerCase() === 'textarea') {
return; // Don't interfere with form inputs
}
switch(e.key) {
case '+':
case '=':
map.zoomIn();
break;
case '-':
map.zoomOut();
break;
case 'h':
case 'H':
map.setView([-2.5, 118], 5); // Home to Indonesia
break;
case 'f':
case 'F':
if (map.isFullscreen && map.isFullscreen()) {
map.toggleFullscreen();
} else if (map.toggleFullscreen) {
map.toggleFullscreen();
}
break;
case 'l':
case 'L':
locateUser();
break;
case 'a':
case 'A':
zoomToItems();
break;
}
});
}
// Enhanced map ready function
function onMapReady() {
addKeyboardShortcuts();
// Add help tooltip
const helpControl = L.control({position: 'bottomright'});
helpControl.onAdd = function(map) {
const div = L.DomUtil.create('div', 'leaflet-control-help');
div.innerHTML = '<i class="fas fa-question-circle" title="Shortcuts: +/- zoom, H home, F fullscreen, L locate, A zoom to all"></i>';
div.style.background = 'rgba(255,255,255,0.8)';
div.style.padding = '5px';
div.style.borderRadius = '3px';
div.style.cursor = 'help';
return div;
};
helpControl.addTo(map);
console.log('🎮 Map keyboard shortcuts enabled: +/- zoom, H home, F fullscreen, L locate, A zoom to all');
}
// Helper functions needed by detail modal
function getItemIcon(typeName) {
switch(typeName) {
case 'OLT': return 'fas fa-server';
case 'Tiang Tumpu': return 'fas fa-tower-broadcast';
case 'ODP': return 'fas fa-project-diagram';
case 'ODC': return 'fas fa-network-wired';
case 'Pelanggan': return 'fas fa-home';
default: return 'fas fa-circle';
}
}
function getStatusBadgeClass(status) {
switch(status) {
case 'active': return 'success';
case 'inactive': return 'secondary';
case 'maintenance': return 'warning';
default: return 'secondary';
}
}
function getStatusText(status) {
switch(status) {
case 'active': return 'Aktif';
case 'inactive': return 'Tidak Aktif';
case 'maintenance': return 'Maintenance';
default: return status || 'Unknown';
}
}
// Export functions to global scope for button access
window.zoomToItems = zoomToItems;
window.zoomToItemType = zoomToItemType;
window.locateUser = locateUser;
window.loadRoutes = loadRoutes;
window.getItemIcon = getItemIcon;
window.getStatusBadgeClass = getStatusBadgeClass;
window.getStatusText = getStatusText;
// Initialize map when document is ready
$(document).ready(function() {
initMap();
setTimeout(onMapReady, 1000); // Wait for map to fully initialize
});

39
auto_login_genieacs.php Normal file
View File

@ -0,0 +1,39 @@
<?php
// auto_login_genieacs.php
// Konfigurasi login GenieACS
$genieacs_host = 'http://172.16.96.10:3000';
$username = 'dwi';
$password = 'jalo';
// URL target setelah login
$target_path = '/#!/devices';
// Mulai cURL untuk login
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $genieacs_host . '/login'); // endpoint login GenieACS
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
'username' => $username,
'password' => $password
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, __DIR__ . '/genieacs_cookie.txt'); // simpan cookie
curl_setopt($ch, CURLOPT_COOKIEFILE, __DIR__ . '/genieacs_cookie.txt');
curl_setopt($ch, CURLOPT_HEADER, false);
// Jalankan login
$response = curl_exec($ch);
if (curl_errno($ch)) {
die('Login error: ' . curl_error($ch));
}
// Redirect user ke halaman target dengan session cookie
curl_close($ch);
// Gunakan header untuk redirect
header('Location: ' . $genieacs_host . $target_path);
exit();
?>

36
auto_login_observium.php Normal file
View File

@ -0,0 +1,36 @@
<?php
// auto_login_observium.php
// Konfigurasi login Observium
$observium_host = 'http://172.16.96.6'; // ganti sesuai IP Observium
$username = 'megadata';
$password = 'megadata';
// Mulai cURL untuk login
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $observium_host . '/login.php'); // endpoint login Observium
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
'username' => $username,
'password' => $password,
'login' => 'Login'
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, __DIR__ . '/observium_cookie.txt'); // simpan cookie
curl_setopt($ch, CURLOPT_COOKIEFILE, __DIR__ . '/observium_cookie.txt');
curl_setopt($ch, CURLOPT_HEADER, false);
// Jalankan login
$response = curl_exec($ch);
if (curl_errno($ch)) {
die('Login error: ' . curl_error($ch));
}
// Redirect user ke halaman dashboard Observium
curl_close($ch);
header('Location: ' . $observium_host . '/'); // ganti path kalau mau ke halaman tertentu
exit();
?>

BIN
bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

22
config/database.php Normal file
View File

@ -0,0 +1,22 @@
<?php
// Konfigurasi Database untuk FTTH Planner
class Database {
private $host = "localhost";
private $db_name = "ftth_planner";
private $username = "root";
private $password = "";
public $conn;
public function getConnection() {
$this->conn = null;
try {
$this->conn = new PDO("mysql:host=" . $this->host . ";dbname=" . $this->db_name, $this->username, $this->password);
$this->conn->exec("set names utf8");
$this->conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch(PDOException $exception) {
echo "Connection error: " . $exception->getMessage();
}
return $this->conn;
}
}
?>

142
database.sql Normal file
View File

@ -0,0 +1,142 @@
-- Database untuk FTTH Planner
CREATE DATABASE IF NOT EXISTS ftth_planner;
USE ftth_planner;
-- Tabel untuk menyimpan jenis item FTTH
CREATE TABLE item_types (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
icon VARCHAR(100),
color VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Tabel untuk menyimpan warna tube
CREATE TABLE tube_colors (
id INT PRIMARY KEY AUTO_INCREMENT,
color_name VARCHAR(30) NOT NULL,
hex_code VARCHAR(7),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Tabel untuk menyimpan jenis splitter
CREATE TABLE splitter_types (
id INT PRIMARY KEY AUTO_INCREMENT,
type VARCHAR(20) NOT NULL, -- 'main' untuk jaringan utama, 'odp' untuk ODP
ratio VARCHAR(10) NOT NULL, -- 1:2, 1:3, 1:4, 1:8, 1:16
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Tabel utama untuk menyimpan item-item FTTH di maps
CREATE TABLE ftth_items (
id INT PRIMARY KEY AUTO_INCREMENT,
item_type_id INT,
name VARCHAR(100) NOT NULL,
description TEXT,
latitude DECIMAL(10, 8) NOT NULL,
longitude DECIMAL(11, 8) NOT NULL,
address TEXT,
tube_color_id INT,
core_used INT COMMENT 'Core yang sedang digunakan dari total kapasitas',
core_color_id INT NULL COMMENT 'Warna core yang digunakan (referensi ke tube_colors)',
item_cable_type ENUM('backbone', 'distribution', 'drop_core', 'feeder', 'branch') NULL DEFAULT 'distribution' COMMENT 'Jenis kabel yang digunakan pada item ini',
total_core_capacity INT NULL DEFAULT 24 COMMENT 'Total kapasitas core untuk item ini',
splitter_main_id INT,
splitter_odp_id INT,
status ENUM('active', 'inactive', 'maintenance') DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (item_type_id) REFERENCES item_types(id),
FOREIGN KEY (tube_color_id) REFERENCES tube_colors(id),
FOREIGN KEY (core_color_id) REFERENCES tube_colors(id),
FOREIGN KEY (splitter_main_id) REFERENCES splitter_types(id),
FOREIGN KEY (splitter_odp_id) REFERENCES splitter_types(id)
);
-- Tabel untuk menyimpan routing kabel
CREATE TABLE cable_routes (
id INT PRIMARY KEY AUTO_INCREMENT,
from_item_id INT,
to_item_id INT,
route_coordinates TEXT, -- JSON array of lat,lng coordinates
distance DECIMAL(8,2), -- dalam meter
cable_type VARCHAR(50),
core_count INT,
status ENUM('planned', 'installed', 'maintenance') DEFAULT 'planned',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (from_item_id) REFERENCES ftth_items(id),
FOREIGN KEY (to_item_id) REFERENCES ftth_items(id)
);
-- Insert data default untuk item types
INSERT INTO item_types (name, icon, color) VALUES
('OLT', 'fas fa-server', '#FF6B6B'),
('Tiang Tumpu', 'fas fa-tower-broadcast', '#4ECDC4'),
('ODP', 'fas fa-project-diagram', '#45B7D1'),
('ODC', 'fas fa-network-wired', '#96CEB4'),
('Pelanggan', 'fas fa-home', '#FFA500');
-- Insert data default untuk warna tube (32 warna total)
INSERT INTO tube_colors (color_name, hex_code) VALUES
('Biru', '#0066CC'),
('Orange', '#FF6600'),
('Hijau', '#00CC66'),
('Coklat', '#996633'),
('Abu-abu', '#666666'),
('Putih', '#FFFFFF'),
('Merah', '#CC0000'),
('Hitam', '#000000'),
('Kuning', '#FFCC00'),
('Violet', '#9900CC'),
('Pink', '#FF6699'),
('Aqua', '#00CCCC'),
('Turquoise', '#40E0D0'),
('Lime', '#32CD32'),
('Magenta', '#FF00FF'),
('Cyan', '#00FFFF'),
('Indigo', '#4B0082'),
('Crimson', '#DC143C'),
('Gold', '#FFD700'),
('Silver', '#C0C0C0'),
('Teal', '#008080'),
('Navy', '#000080'),
('Coral', '#FF7F50'),
('Salmon', '#FA8072'),
('Lavender', '#E6E6FA'),
('Beige', '#F5F5DC'),
('Olive', '#808000'),
('Maroon', '#800000'),
('Khaki', '#F0E68C'),
('Plum', '#DDA0DD'),
('Bronze', '#CD7F32'),
('Emerald', '#50C878');
-- Insert data default untuk splitter types
INSERT INTO splitter_types (type, ratio) VALUES
('main', '1:2'),
('main', '1:3'),
('main', '1:4'),
('odp', '1:2'),
('odp', '1:4'),
('odp', '1:8'),
('odp', '1:16');
-- Create indexes for performance optimization
CREATE INDEX idx_core_color ON ftth_items(core_color_id);
CREATE INDEX idx_cable_type ON ftth_items(item_cable_type);
CREATE INDEX idx_core_usage ON ftth_items(core_used, total_core_capacity);
-- Tabel untuk menyimpan data user
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role ENUM('engineer', 'teknisi') NOT NULL DEFAULT 'teknisi',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Contoh user default (password: admin123 untuk engineer, teknisi123 untuk teknisi)
INSERT INTO users (username, password, role) VALUES
('admin', MD5('admin123'), 'engineer'),
('teknisi', MD5('teknisi123'), 'teknisi');

37
get_clients.php Normal file
View File

@ -0,0 +1,37 @@
<?php
session_start();
require 'config/database.php'; // koneksi database
header('Content-Type: application/json');
try {
$database = new Database();
$db = $database->getConnection();
// Ambil hanya data yang punya koordinat valid
$query = "SELECT
id,
name,
description,
latitude,
longitude,
address,
serial_number,
status
FROM ftth_items
WHERE latitude IS NOT NULL AND longitude IS NOT NULL";
$stmt = $db->prepare($query);
$stmt->execute();
$clients = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode([
"success" => true,
"data" => $clients
]);
} catch (Exception $e) {
echo json_encode([
"success" => false,
"message" => "Gagal mengambil data client: " . $e->getMessage()
]);
}

43
get_rx_power.php Normal file
View File

@ -0,0 +1,43 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
header('Content-Type: application/json');
$serialNumber = $_GET['sn'] ?? '';
if (empty($serialNumber)) {
echo json_encode(['rx_power' => null, 'error' => 'Serial number kosong']);
exit;
}
function getRxPowerFromGenieACS($serialNumber) {
$query = json_encode(["_id" => $serialNumber]);
$encodedQuery = urlencode($query); // penting untuk URL-safe
$url = "http://localhost:7557/devices/?query={$encodedQuery}";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
if ($response === false) {
return ['rx_power' => null, 'error' => curl_error($ch)];
}
curl_close($ch);
$data = json_decode($response, true);
if (!isset($data[0]['VirtualParameters']['RXPower']['_value'])) {
return ['rx_power' => null, 'error' => 'RX Power tidak ditemukan'];
}
$rxPowerValue = $data[0]['VirtualParameters']['RXPower']['_value'];
return ['rx_power' => $rxPowerValue];
}
$result = getRxPowerFromGenieACS($serialNumber);
echo json_encode($result);

15
hapus.php Normal file
View File

@ -0,0 +1,15 @@
<?php
require 'config/database.php'; // koneksi PDO
if (isset($_GET['delete'])) {
$id = intval($_GET['delete']); // pastikan integer
if ($id > 0) {
$stmt = $pdo->prepare("DELETE FROM items WHERE id = :id");
$stmt->execute(['id' => $id]);
}
header("Location: index.php");
exit;
}
?>

651
index.php Normal file
View File

@ -0,0 +1,651 @@
<?php
session_start();
if (!isset($_SESSION['user'])) {
header("Location: login.php");
exit;
}
// Akses teknisi tidak diarahkan ke map.php, tetap di dashboard
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FTTH Megadata | Dashboard</title>
<!-- Google Font: Source Sans Pro -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,400i,700&display=fallback">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- AdminLTE -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/admin-lte@3.2/dist/css/adminlte.min.css">
<!-- Leaflet CSS (Latest Version) -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- Leaflet Fullscreen Plugin -->
<link rel="stylesheet" href="https://unpkg.com/leaflet-fullscreen@1.0.1/dist/leaflet.fullscreen.css" />
<!-- Leaflet Routing Machine -->
<link rel="stylesheet" href="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.css" />
<!-- Custom CSS -->
<link rel="stylesheet" href="assets/css/custom.css">
</head>
<body class="hold-transition sidebar-mini layout-fixed">
<div class="wrapper">
<!-- Preloader -->
<div class="preloader flex-column justify-content-center align-items-center">
<i class="fas fa-network-wired fa-3x text-primary"></i>
<h4 class="mt-3">FTTH Megadata</h4>
</div>
<!-- Navbar -->
<nav class="main-header navbar navbar-expand navbar-white navbar-light">
<!-- Left navbar links -->
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" data-widget="pushmenu" href="#" role="button"><i class="fas fa-bars"></i></a>
</li>
<li class="nav-item d-none d-sm-inline-block">
<a href="index.php" class="nav-link">Dashboard</a>
</li>
</ul>
<!-- Right navbar links -->
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="logout.php" role="button">
<i class="fas fa-sign-out-alt"></i> Logout
</a>
</li>
</ul>
</nav>
<!-- Main Sidebar Container -->
<aside class="main-sidebar sidebar-dark-primary elevation-4">
<!-- Brand Logo -->
<a href="index.php" class="brand-link">
<i class="fas fa-network-wired brand-image img-circle elevation-3" style="opacity: .8; margin-left: 10px; color: white;"></i>
<span class="brand-text font-weight-light">FTTH Megadata</span>
</a>
<!-- Sidebar -->
<div class="sidebar">
<!-- Sidebar Menu -->
<nav class="mt-2">
<ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false">
<li class="nav-item">
<a href="#" class="nav-link active">
<i class="nav-icon fas fa-map"></i>
<p>Peta FTTH</p>
</a>
</li>
<!-- //ini adalah tombol genieacs lama
<li class="nav-item">
<a href="http://172.16.96.10:3000/" class="nav-link" target="_blank">
<i class="nav-icon fas fa-server"></i>
<p>GenieACS</p>
</a>
</li>
<li class="nav-item">
<a href="url_observium.html" class="nav-link" target="_blank">
<i class="nav-icon fas fa-chart-line"></i>
<p>Observium</p>
</a>
</li>
!-->
<!-- Tombol Manajemen Users -->
<li class="nav-item">
<a href="manajemen_users.php" class="nav-link">
<i class="nav-icon fas fa-users"></i>
<p>Users</p>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="showItemList()">
<i class="nav-icon fas fa-list"></i>
<p>Daftar Item</p>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="showRouteList()">
<i class="nav-icon fas fa-route"></i>
<p>Routing Kabel</p>
</a>
</li>
<li class="nav-header">NAVIGASI PETA</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="zoomToItems()">
<i class="nav-icon fas fa-expand-arrows-alt" style="color: #17a2b8;"></i>
<p>Zoom Semua Item</p>
</a>
</li>
<li class="nav-item has-treeview">
<a href="#" class="nav-link">
<i class="nav-icon fas fa-search-location" style="color: #6f42c1;"></i>
<p>
Zoom ke Item
<i class="right fas fa-angle-left"></i>
</p>
</a>
<ul class="nav nav-treeview">
<li class="nav-item">
<a href="#" class="nav-link" onclick="zoomToItemType('OLT')">
<i class="fas fa-server nav-icon" style="color: #FF6B6B;"></i>
<p>Zoom ke OLT</p>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="zoomToItemType('Tiang Tumpu')">
<i class="fas fa-tower-broadcast nav-icon" style="color: #4ECDC4;"></i>
<p>Zoom ke Tiang</p>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="zoomToItemType('ODP')">
<i class="fas fa-project-diagram nav-icon" style="color: #45B7D1;"></i>
<p>Zoom ke ODP</p>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="zoomToItemType('ODC')">
<i class="fas fa-network-wired nav-icon" style="color: #96CEB4;"></i>
<p>Zoom ke ODC</p>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="zoomToItemType('Pelanggan')">
<i class="fas fa-home nav-icon" style="color: #FFA500;"></i>
<p>Zoom ke Pelanggan</p>
</a>
</li>
</ul>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="locateUser()">
<i class="nav-icon fas fa-location-arrow" style="color: #dc3545;"></i>
<p>Cari Lokasi Saya</p>
</a>
</li>
<li class="nav-header">EXPORT DATA</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="exportToKMZ()">
<i class="nav-icon fas fa-download" style="color: #28a745;"></i>
<p>Export ke KMZ</p>
</a>
</li>
<li class="nav-header">ITEM FTTH</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="addNewItem('OLT')">
<i class="nav-icon fas fa-server" style="color: #FF6B6B;"></i>
<p>Tambah OLT</p>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="addNewItem('Tiang Tumpu')">
<i class="nav-icon fas fa-tower-broadcast" style="color: #4ECDC4;"></i>
<p>Tambah Tiang Tumpu</p>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="addNewItem('ODP')">
<i class="nav-icon fas fa-project-diagram" style="color: #45B7D1;"></i>
<p>Tambah ODP</p>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="addNewItem('ODC')">
<i class="nav-icon fas fa-network-wired" style="color: #96CEB4;"></i>
<p>Tambah ODC</p>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" onclick="addNewItem('Pelanggan')">
<i class="nav-icon fas fa-home" style="color: #FFA500;"></i>
<p>Tambah Pelanggan</p>
</a>
</li>
</ul>
</nav>
</div>
</aside>
<!-- Content Wrapper -->
<div class="content-wrapper">
<!-- Content Header -->
<div class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1 class="m-0">Dashboard FTTH Megadata</h1>
</div>
<div class="col-sm-6">
<ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item"><a href="#">Home</a></li>
<li class="breadcrumb-item active">Dashboard</li>
</ol>
</div>
</div>
</div>
</div>
<!-- Main content -->
<section class="content">
<div class="container-fluid">
<!-- Map Container -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-map mr-1"></i>
Peta Infrastruktur FTTH
</h3>
<div class="card-tools d-flex justify-content-between">
<!-- Kanan: tombol utama -->
<div class="btn-group">
<?php if ($_SESSION["role"] === "engineer") : ?>
<button type="button" class="btn btn-primary btn-sm" onclick="showAddItemModal()">
<?php else: ?>
<button type="button" class="btn btn-primary btn-sm" disabled style="opacity:0.5;cursor:not-allowed;">
<?php endif; ?>
<i class="fas fa-plus"></i> Tambah Item
</button>
<?php if ($_SESSION["role"] === "engineer") : ?>
<button type="button" class="btn btn-info btn-sm" onclick="showRoutingMode()">
<?php else: ?>
<button type="button" class="btn btn-info btn-sm" disabled style="opacity:0.5;cursor:not-allowed;">
<?php endif; ?>
<i class="fas fa-route"></i> Mode Routing
</button>
<button type="button" class="btn btn-success btn-sm" onclick="exportToKMZ()">
<i class="fas fa-download"></i> Export KMZ
</button>
</div>
<!-- Tombol GenieACS & Observium di kanan -->
<div class="btn-group ml-2">
<button type="button" class="btn btn-warning btn-sm" onclick="window.open('auto_login_genieacs.php', '_blank')">
<i class="fas fa-server"></i> ACS
</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="window.open('auto_login_observium.php', '_blank')">
<i class="fas fa-chart-line"></i> Observium
</button>
</div>
</div>
<!-- Map Zoom Controls -->
<div class="map-zoom-controls">
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="zoomToItems()" title="Zoom ke Semua Item">
<i class="fas fa-expand-arrows-alt"></i>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="locateUser()" title="Cari Lokasi Saya">
<i class="fas fa-location-arrow"></i>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="map.setView([-2.5, 118], 5)" title="Zoom ke Indonesia">
<i class="fas fa-home"></i>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="map.zoomIn()" title="Zoom In (+)">
<i class="fas fa-plus"></i>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="map.zoomOut()" title="Zoom Out (-)">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
</div>
<div class="card-body p-0">
<div id="map" style="height: 600px;"></div>
</div>
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="row">
<div class="col-lg-2 col-6">
<div class="small-box bg-info">
<div class="inner">
<h3 id="stat-olt">0</h3>
<p>OLT</p>
</div>
<div class="icon">
<i class="fas fa-server"></i>
</div>
</div>
</div>
<div class="col-lg-2 col-6">
<div class="small-box bg-success">
<div class="inner">
<h3 id="stat-tiang">0</h3>
<p>Tiang</p>
</div>
<div class="icon">
<i class="fas fa-tower-broadcast"></i>
</div>
</div>
</div>
<div class="col-lg-2 col-6">
<div class="small-box bg-warning">
<div class="inner">
<h3 id="stat-odp">0</h3>
<p>ODP</p>
</div>
<div class="icon">
<i class="fas fa-project-diagram"></i>
</div>
</div>
</div>
<div class="col-lg-2 col-6">
<div class="small-box bg-danger">
<div class="inner">
<h3 id="stat-odc">0</h3>
<p>ODC</p>
</div>
<div class="icon">
<i class="fas fa-network-wired"></i>
</div>
</div>
</div>
<div class="col-lg-2 col-6">
<div class="small-box bg-primary">
<div class="inner">
<h3 id="stat-pelanggan">0</h3>
<p>Pelanggan</p>
</div>
<div class="icon">
<i class="fas fa-home"></i>
</div>
</div>
</div>
<div class="col-lg-2 col-6">
<div class="small-box bg-secondary">
<div class="inner">
<h3 id="stat-routes">0</h3>
<p>Routes</p>
</div>
<div class="icon">
<i class="fas fa-route"></i>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
<!-- Footer -->
<footer class="main-footer">
<strong>Copyright &copy; 2025 <a href="#">FTTH Megadata</a> by Kuli Jaringan.</strong>
Semua hak dilindungi undang-undang.
<div class="float-right d-none d-sm-inline-block">
<b>Versi</b> 1.0.0
</div>
</footer>
</div>
<!-- Add/Edit Item Modal -->
<div class="modal fade" id="itemModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="itemModalTitle">Tambah Item FTTH</h4>
<button type="button" class="close" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<form id="itemForm">
<div class="modal-body">
<input type="hidden" id="itemId" name="id">
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="itemType">Jenis Item</label>
<select class="form-control" id="itemType" name="item_type" required>
<option value="">Pilih Jenis Item</option>
<option value="1">OLT</option>
<option value="2">Tiang Tumpu</option>
<option value="3">ODP</option>
<option value="4">ODC</option>
<option value="5">Pelanggan</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="itemName">Nama Item</label>
<input type="text" class="form-control" id="itemName" name="name" required>
</div>
</div>
</div>
<div class="form-group">
<label for="itemDescription">Deskripsi</label>
<textarea class="form-control" id="itemDescription" name="description" rows="3"></textarea>
</div>
<div class="form-group">
<label for="itemAddress">Alamat</label>
<textarea class="form-control" id="itemAddress" name="address" rows="2"></textarea>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="itemLat">Latitude <span class="text-danger">*</span></label>
<input type="number" step="any" class="form-control" id="itemLat" name="latitude" placeholder="Contoh: -6.2088">
<small class="form-text text-muted">Koordinat Lintang (Latitude)</small>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="itemLng">Longitude <span class="text-danger">*</span></label>
<input type="number" step="any" class="form-control" id="itemLng" name="longitude" placeholder="Contoh: 106.8456">
<small class="form-text text-muted">Koordinat Bujur (Longitude)</small>
</div>
</div>
</div>
<div class="alert alert-info" role="alert">
<i class="fas fa-info-circle"></i>
<strong>Cara menentukan lokasi:</strong>
<br> Klik tombol "Pilih di Peta" di bawah ini
<br> Atau tutup dialog ini dan klik langsung di peta, lalu pilih "Tambah Item"
</div>
<div class="text-center mb-3">
<button type="button" class="btn btn-outline-primary" onclick="selectLocationOnMap()">
<i class="fas fa-map-marker-alt"></i> Pilih Lokasi di Peta
</button>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="tubeColor">Warna Tube</label>
<select class="form-control" id="tubeColor" name="tube_color_id">
<option value="">Pilih Warna Tube</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="coreColor">Warna Core</label>
<select class="form-control" id="coreColor" name="core_color_id">
<option value="">Pilih Warna Core</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="cableType">Jenis Kabel</label>
<select class="form-control" id="cableType" name="item_cable_type">
<option value="">Pilih Jenis Kabel</option>
<option value="backbone">Backbone</option>
<option value="distribution">Distribution</option>
<option value="drop_core">Drop Core</option>
<option value="feeder">Feeder</option>
<option value="branch">Branch</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="totalCoreCapacity">Kapasitas Core Total</label>
<select class="form-control" id="totalCoreCapacity" name="total_core_capacity">
<option value="2">2 Core</option>
<option value="4">4 Core</option>
<option value="6">6 Core</option>
<option value="8">8 Core</option>
<option value="12">12 Core</option>
<option value="24" selected>24 Core</option>
<option value="48">48 Core</option>
<option value="72">72 Core</option>
<option value="96">96 Core</option>
<option value="144">144 Core</option>
<option value="216">216 Core</option>
<option value="288">288 Core</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="coreUsed">Core yang Digunakan</label>
<input type="number" class="form-control" id="coreUsed" name="core_used" min="0" max="288">
<small class="form-text text-muted">Otomatis terisi dari perhitungan routing</small>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label>Core Tersedia</label>
<input type="text" class="form-control" id="coreAvailable" readonly>
<small class="form-text text-muted">Kapasitas Total - Core Digunakan</small>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="splitterMain">Splitter Jaringan Utama</label>
<select class="form-control" id="splitterMain" name="splitter_main_id">
<option value="">Pilih Splitter Utama</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="splitterOdp">Splitter ODP</label>
<select class="form-control" id="splitterOdp" name="splitter_odp_id">
<option value="">Pilih Splitter ODP</option>
</select>
</div>
</div>
</div>
<div class="form-group">
<label for="serial_number">Serial Number ONT</label>
<input type="text" class="form-control" id="serial_number" name="serial_number" placeholder="Contoh: ZTEGC1234567" required>
</div>
<div class="form-group">
<label for="rx_power">RX Power (dBm)</label>
<input type="number" step="0.01" class="form-control" id="rx_power" name="rx_power" placeholder="Otomatis dari GenieACS" readonly>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const snInput = document.getElementById('serial_number');
const rxPowerInput = document.getElementById('rx_power');
snInput.addEventListener('change', function() {
const sn = this.value.trim();
if (!sn) return;
fetch('get_rx_power.php?sn=' + encodeURIComponent(sn))
.then(response => {
if (!response.ok) {
throw new Error('Network response was not OK');
}
return response.json();
})
.then(data => {
// console.log(data);
if (data.rx_power !== null) {
rxPowerInput.value = data.rx_power;
} else {
alert('⚠ ' + (data.error || 'RX Power tidak ditemukan'));
rxPowerInput.value = '';
}
})
.catch(error => {
// console.error('Fetch error:', error);
alert('❌ Gagal mengambil RX Power dari GenieACS');
rxPowerInput.value = '';
});
});
});
</script>
<script>
</script>
<div class="form-group">
<label for="itemStatus">Status</label>
<select class="form-control" id="itemStatus" name="status">
<option value="active">Aktif</option>
<option value="inactive">Tidak Aktif</option>
<option value="maintenance">Maintenance</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Batal</button>
<button type="submit" class="btn btn-primary">Simpan</button>
</div>
</form>
</div>
</div>
</div>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- Bootstrap 4 -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js"></script>
<!-- AdminLTE App -->
<script src="https://cdn.jsdelivr.net/npm/admin-lte@3.2/dist/js/adminlte.min.js"></script>
<!-- Leaflet JS (Latest Version) -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Leaflet Fullscreen Plugin -->
<script src="https://unpkg.com/leaflet-fullscreen@1.0.1/dist/leaflet.fullscreen.js"></script>
<!-- Leaflet Routing Machine -->
<script src="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.js"></script>
<!-- JSZip for KMZ compression -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<!-- FileSaver for download -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
<!-- Custom JS -->
<script src="assets/js/map.js"></script>
<script src="assets/js/app.js"></script>
<script src="assets/js/kmz-export.js"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet-control-geocoder/dist/Control.Geocoder.css" />
<script src="https://unpkg.com/leaflet-control-geocoder/dist/Control.Geocoder.js"></script>
</body>
</html>

129
login.php Normal file
View File

@ -0,0 +1,129 @@
<?php
session_start();
require 'config/database.php'; // memanggil class Database
// Buat koneksi PDO
$db = new Database();
$conn = $db->getConnection();
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$username = trim($_POST['username']);
$password = trim($_POST['password']);
try {
$stmt = $conn->prepare("SELECT * FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($password, $user['password'])) {
$_SESSION['user'] = $user['username'];
$_SESSION['role'] = $user['role'];
header("Location: index.php");
exit;
} else {
$error = "Username atau password salah!";
}
} catch (PDOException $e) {
$error = "Terjadi kesalahan: " . $e->getMessage();
}
}
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - FTTH Megadata</title>
<style>
body {
margin: 0;
font-family: Arial, sans-serif;
height: 100vh;
background: url('bg.jpg') no-repeat center center fixed;
background-size: cover;
display: flex;
align-items: center;
justify-content: center;
padding: 15px;
}
.login-container {
background: rgba(255, 255, 255, 0.9);
padding: 40px;
border-radius: 15px;
box-shadow: 0 0 15px rgba(0,0,0,0.2);
text-align: center;
width: 320px;
max-width: 100%;
}
.login-container img {
max-width: 150px;
height: auto;
border-radius: 0;
object-fit: contain;
margin-bottom: 15px;
}
.login-container h2 {
margin-bottom: 20px;
color: #333;
}
.login-container input {
width: 100%;
padding: 12px;
margin: 8px 0;
border: none;
border-radius: 30px;
background: #f1f1f1;
outline: none;
font-size: 14px;
}
.login-container button {
width: 100%;
padding: 12px;
border: none;
border-radius: 30px;
background: #333;
color: #fff;
font-size: 14px;
cursor: pointer;
}
.login-container button:hover {
background: #555;
}
.login-container p {
font-size: 12px;
margin-top: 15px;
}
.login-container a {
color: #0066cc;
text-decoration: none;
}
.error {
color: red;
margin-bottom: 10px;
}
/* Responsif untuk layar kecil */
@media (max-width: 480px) {
.login-container {
padding: 25px;
width: 100%;
}
.login-container img {
max-width: 100px;
}
}
</style>
</head>
<body>
<div class="login-container">
<img src="megadata.jpeg" alt="Logo">
<h2>Login</h2>
<?php if (!empty($error)) echo "<p class='error'>$error</p>"; ?>
<form method="POST">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>

5
logout.php Normal file
View File

@ -0,0 +1,5 @@
<?php
session_start();
session_destroy();
header("Location: login.php");
exit;

203
manajemen_users.php Normal file
View File

@ -0,0 +1,203 @@
<?php
session_start();
require 'config/database.php'; // gunakan class Database
// Buat koneksi PDO
$db = new Database();
$conn = $db->getConnection();
// CREATE
if (isset($_POST['create'])) {
$username = trim($_POST['username']);
$password = password_hash(trim($_POST['password']), PASSWORD_BCRYPT);
$role = $_POST['role'];
$stmt = $conn->prepare("INSERT INTO users (username, password, role) VALUES (?, ?, ?)");
$stmt->execute([$username, $password, $role]);
header("Location: manajemen_users.php");
exit;
}
// UPDATE
if (isset($_POST['update'])) {
$id = $_POST['id'];
$username = trim($_POST['username']);
$role = $_POST['role'];
if (!empty($_POST['password'])) {
$password = password_hash(trim($_POST['password']), PASSWORD_BCRYPT);
$stmt = $conn->prepare("UPDATE users SET username=?, password=?, role=? WHERE id=?");
$stmt->execute([$username, $password, $role, $id]);
} else {
$stmt = $conn->prepare("UPDATE users SET username=?, role=? WHERE id=?");
$stmt->execute([$username, $role, $id]);
}
header("Location: manajemen_users.php");
exit;
}
// DELETE
if (isset($_GET['delete'])) {
$id = $_GET['delete'];
$stmt = $conn->prepare("DELETE FROM users WHERE id=?");
$stmt->execute([$id]);
header("Location: manajemen_users.php");
exit;
}
// READ
$stmt = $conn->query("SELECT * FROM users");
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>Manajemen Users - FTTH Megadata</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<style>
body {
margin: 0;
font-family: Arial, sans-serif;
height: 100vh;
background: url('bg.jpg') no-repeat center center fixed;
background-size: cover;
display: flex;
align-items: center;
justify-content: center;
padding: 15px;
}
.users-container {
background: rgba(255, 255, 255, 0.92);
padding: 30px;
border-radius: 15px;
box-shadow: 0 0 15px rgba(0,0,0,0.2);
width: 90%;
max-width: 900px;
}
h2 {
text-align: center;
margin-bottom: 20px;
color: #333;
}
.table thead {
background: #333;
color: #fff;
}
.btn-custom {
border-radius: 30px;
padding: 8px 16px;
}
</style>
</head>
<body>
<div class="users-container">
<h2>👥 Manajemen Users</h2>
<!-- Link ke Register -->
<div class="mb-3 text-end">
<a href="register.php" class="btn btn-success btn-custom">+ Daftar User Baru</a>
</div>
<!--
Form Tambah User
<div class="card mb-4">
<div class="card-header">Tambah User</div>
<div class="card-body">
<form method="POST">
<div class="row">
<div class="col-md-4 mb-2">
<input type="text" name="username" class="form-control" placeholder="Username" required>
</div>
<div class="col-md-4 mb-2">
<input type="password" name="password" class="form-control" placeholder="Password" required>
</div>
<div class="col-md-3 mb-2">
<select name="role" class="form-control" required>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
</div>
<div class="col-md-1 mb-2">
<button type="submit" name="create" class="btn btn-primary w-100">Tambah</button>
</div>
</div>
</form>
</div>
</div>
-->
<!-- Tabel Users -->
<div class="table-responsive">
<table class="table table-bordered align-middle text-center">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Role</th>
<th style="width: 180px;">Aksi</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $row): ?>
<tr>
<td><?= $row['id'] ?></td>
<td><?= $row['username'] ?></td>
<td><?= $row['role'] ?></td>
<td>
<!-- Tombol Edit -->
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#edit<?= $row['id'] ?>">Edit</button>
<!-- Tombol Hapus -->
<a href="?delete=<?= $row['id'] ?>" class="btn btn-danger btn-sm" onclick="return confirm('Yakin hapus user ini?')">Hapus</a>
</td>
</tr>
<!-- Modal Edit -->
<div class="modal fade" id="edit<?= $row['id'] ?>" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST">
<div class="modal-header">
<h5 class="modal-title">Edit User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" name="id" value="<?= $row['id'] ?>">
<div class="mb-2">
<input type="text" name="username" class="form-control" value="<?= $row['username'] ?>" required>
</div>
<div class="mb-2">
<input type="password" name="password" class="form-control" placeholder="Password baru (opsional)">
</div>
<div class="mb-2">
<select name="role" class="form-control">
<option value="admin" <?= $row['role']=="admin"?"selected":"" ?>>Admin</option>
<option value="user" <?= $row['role']=="user"?"selected":"" ?>>User</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="update" class="btn btn-primary">Simpan</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Batal</button>
</div>
</form>
</div>
</div>
</div>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Tombol kembali ke dashboard -->
<div class="text-center mt-3">
<a href="index.php" class="btn btn-dark btn-custom"> Kembali ke Dashboard</a>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

BIN
megadata.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

133
register.php Normal file
View File

@ -0,0 +1,133 @@
<?php
session_start();
require 'config/database.php';
// Buat koneksi database
$database = new Database();
$conn = $database->getConnection();
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$username = trim($_POST['username']);
$password = password_hash(trim($_POST['password']), PASSWORD_DEFAULT);
$role = $_POST['role'];
try {
$stmt = $conn->prepare("INSERT INTO users (username, password, role) VALUES (?, ?, ?)");
$stmt->execute([$username, $password, $role]);
$success = "Pendaftaran berhasil! Silakan login.";
} catch (PDOException $e) {
$error = "Username sudah digunakan atau terjadi kesalahan.";
}
}
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register - FTTH Megadata</title>
<style>
body {
margin: 0;
font-family: Arial, sans-serif;
height: 100vh;
background: url('bg.jpg') no-repeat center center fixed;
background-size: cover;
display: flex;
align-items: center;
justify-content: center;
padding: 15px;
}
.register-container {
background: rgba(255, 255, 255, 0.9);
padding: 40px;
border-radius: 15px;
box-shadow: 0 0 15px rgba(0,0,0,0.2);
text-align: center;
width: 320px;
max-width: 100%;
}
.register-container img {
max-width: 150px;
height: auto;
object-fit: contain;
margin-bottom: 15px;
}
.register-container h2 {
margin-bottom: 20px;
color: #333;
}
.register-container input,
.register-container select {
width: 100%;
padding: 12px;
margin: 8px 0;
border: none;
border-radius: 30px;
background: #f1f1f1;
outline: none;
font-size: 14px;
}
.register-container button {
width: 100%;
padding: 12px;
border: none;
border-radius: 30px;
background: #333;
color: #fff;
font-size: 14px;
cursor: pointer;
}
.register-container button:hover {
background: #555;
}
.register-container p {
font-size: 12px;
margin-top: 15px;
}
.register-container a {
color: #0066cc;
text-decoration: none;
}
.success {
color: green;
margin-bottom: 10px;
}
.error {
color: red;
margin-bottom: 10px;
}
/* Responsif untuk layar kecil */
@media (max-width: 480px) {
.register-container {
padding: 25px;
}
.register-container img {
max-width: 100px;
}
}
</style>
</head>
<body>
<div class="register-container">
<img src="megadata.jpeg" alt="Logo">
<h2>Daftar Akun</h2>
<?php
if (!empty($success)) echo "<p class='success'>$success</p>";
if (!empty($error)) echo "<p class='error'>$error</p>";
?>
<form method="POST">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
<select name="role" required>
<option value="">Pilih Role</option>
<option value="engineer">Administrator</option>
<option value="teknisi">Teknisi</option>
</select>
<button type="submit">Daftar</button>
</form>
<p>Sudah punya akun? <a href="login.php">Login di sini</a></p>
</div>
</body>
</html>

100
setup_instructions.md Normal file
View File

@ -0,0 +1,100 @@
# Instruksi Setup FTTH Planner
## Langkah 1: Persiapan XAMPP
1. **Download XAMPP**: Unduh dari https://www.apachefriends.org/
2. **Install XAMPP**: Install di `C:\xampp\`
3. **Start Services**:
- Buka XAMPP Control Panel
- Start **Apache** dan **MySQL**
## Langkah 2: Setup Database
1. **Buka phpMyAdmin**:
- Browser → `http://localhost/phpmyadmin`
- Login tanpa password (default)
2. **Buat Database**:
- Klik "New" di sidebar kiri
- Nama database: `ftth_planner`
- Collation: `utf8_general_ci`
- Klik "Create"
3. **Import Database**:
- Pilih database `ftth_planner`
- Tab "Import"
- Choose file: pilih `database.sql`
- Klik "Go"
## Langkah 3: Copy File Aplikasi
1. **Copy folder**: Copy semua file ke `C:\xampp\htdocs\ftthplanner\`
2. **Struktur folder harus**:
```
C:\xampp\htdocs\ftthplanner\
├── index.php
├── config/database.php
├── api/ (semua file API)
├── assets/ (CSS & JS)
├── database.sql
└── README.md
```
## Langkah 4: Test Aplikasi
1. **Buka Browser**: `http://localhost/ftthplanner`
2. **Cek Map**: Pastikan peta OpenStreetMaps muncul
3. **Test Add Item**: Klik di peta → isi form → simpan
4. **Test Drag**: Drag marker yang sudah dibuat
5. **Test Route**: Klik "Mode Routing" → pilih 2 item
## Troubleshooting Umum
### Error: "Connection failed"
- Pastikan MySQL service berjalan di XAMPP
- Cek username/password di `config/database.php`
### Error: "Table doesn't exist"
- Import ulang `database.sql`
- Pastikan database name = `ftth_planner`
### Map tidak muncul
- Cek koneksi internet
- Allow JavaScript di browser
- Cek console browser (F12) untuk error
### Item tidak bisa disimpan
- Cek permission folder (777 di Linux/Mac)
- Pastikan semua field required diisi
- Lihat Network tab di browser untuk error API
## Konfigurasi Lanjutan
### Ganti Default Location Map
Edit di `assets/js/map.js` baris 11:
```javascript
map = L.map('map').setView([-6.2088, 106.8456], 11);
```
Ganti koordinat dengan lokasi yang diinginkan.
### Tambah Warna Tube
Insert ke database:
```sql
INSERT INTO tube_colors (color_name, hex_code) VALUES ('Nama Warna', '#HEX_CODE');
```
### Tambah Jenis Splitter
Insert ke database:
```sql
INSERT INTO splitter_types (type, ratio) VALUES ('main', '1:6');
```
## Kontak Support
Jika mengalami masalah, dokumentasikan:
1. Versi Windows
2. Versi XAMPP
3. Error message lengkap
4. Screenshot jika perlu
Aplikasi sudah siap digunakan! 🎉

144
testing_guide.md Normal file
View File

@ -0,0 +1,144 @@
# Panduan Testing FTTH Planner
## ✅ Masalah yang Telah Diperbaiki
### 1. **Item Pelanggan/End User**
- ✅ Tambah item type "Pelanggan" dengan icon rumah (🏠)
- ✅ Warna orange (#FFA500) untuk pembeda
- ✅ Menu sidebar "Tambah Pelanggan"
- ✅ Form dapat memilih "Pelanggan"
- ✅ Statistik menampilkan jumlah pelanggan
### 2. **Routing Cable (From-To)**
- ✅ Perbaiki bug routing yang tidak berfungsi
- ✅ Tambah error handling dan logging
- ✅ Fallback ke garis lurus jika routing gagal
- ✅ Console log untuk debugging
## 🔧 Cara Setup & Update
### Jika Database Sudah Ada
Jalankan file `update_database.sql` di phpMyAdmin untuk menambah item type Pelanggan:
```sql
INSERT IGNORE INTO item_types (id, name, icon, color) VALUES (5, 'Pelanggan', 'fas fa-home', '#FFA500');
```
### Jika Database Baru
Import file `database.sql` yang sudah diupdate dengan item Pelanggan.
## 🧪 Testing Checklist
### Test 1: Item Pelanggan
1. **Buka aplikasi**: `http://localhost/ftthplanner`
2. **Cek sidebar**: Menu "Tambah Pelanggan" ada dengan icon 🏠
3. **Klik "Tambah Pelanggan"**:
- Form modal terbuka
- Item Type otomatis terisi "Pelanggan"
- Isi nama: "Rumah Pak Budi"
- Klik di peta untuk set lokasi
- Klik "Simpan"
4. **Verifikasi**:
- Marker orange dengan icon rumah muncul di peta
- Statistik "Pelanggan" bertambah
- Popup info benar saat diklik marker
### Test 2: Routing Cable
1. **Buat minimal 2 item** (misal: 1 OLT + 1 Pelanggan)
2. **Klik tombol "Mode Routing"** di header card
3. **Klik marker pertama** (misal OLT)
4. **Klik marker kedua** (misal Pelanggan)
5. **Verifikasi**:
- Garis route muncul (kuning putus-putus)
- Notifikasi "Route berhasil dibuat"
- Statistik "Routes" bertambah
### Test 3: Routing Alternative
Jika routing normal gagal, akan otomatis fallback ke garis lurus:
1. **Buka Console Browser** (F12)
2. **Coba buat route**
3. **Cek console log**:
- "Creating route from [lat,lng] to [lat,lng]"
- Jika ada error: "Leaflet Routing Machine not available"
- "Route sederhana berhasil dibuat"
### Test 4: Drag & Drop
1. **Drag marker** yang sudah dibuat ke lokasi baru
2. **Verifikasi**: Notifikasi "Posisi item berhasil dipindahkan"
### Test 5: Edit/Delete
1. **Klik marker → Edit**: Form terisi data lama
2. **Update** dan simpan: Data terupdate
3. **Klik marker → Hapus**: Item terhapus dari peta
## 🐛 Troubleshooting
### Routing Tidak Berfungsi
**Gejala**: Tidak ada garis route muncul setelah klik 2 marker
**Solusi**:
1. **Cek Console** (F12):
- Error loading Leaflet Routing Machine?
- Network error ke OSRM service?
2. **Cek Internet**: Routing butuh koneksi untuk akses map service
3. **Fallback**: Sistem otomatis buat garis lurus jika routing gagal
**Test Manual**:
```javascript
// Di console browser
console.log(typeof L.Routing); // Should show 'object'
console.log(markers); // Should show object with marker IDs
```
### Item Pelanggan Tidak Muncul
**Solusi**:
1. **Cek Database**: Pastikan item_types id=5 ada
2. **Hard Refresh**: Ctrl+F5 untuk reload cache
3. **Cek Console**: Error JavaScript?
### Statistik Tidak Update
**Gejala**: Angka statistik tidak berubah setelah tambah item
**Solusi**:
1. **Refresh halaman**: F5
2. **Cek API**: `http://localhost/ftthplanner/api/statistics.php`
3. **Cek Database**: Data tersimpan di tabel ftth_items?
## 📊 Expected Results
### Statistik Dashboard
- **OLT**: Jumlah OLT yang dibuat
- **Tiang**: Jumlah Tiang Tumpu
- **ODP**: Jumlah ODP
- **ODC**: Jumlah ODC
- **Pelanggan**: Jumlah Pelanggan ⭐ (BARU)
- **Routes**: Jumlah route cable ⭐ (BARU)
### Legend Map
Harus menampilkan 5 item:
- 🔴 OLT
- 🔵 Tiang Tumpu
- 🔵 ODP
- 🟢 ODC
- 🟠 Pelanggan ⭐ (BARU)
### Routing Visual
- **Garis hijau solid**: Route terpasang
- **Garis kuning putus**: Route perencanaan ⭐
- **Garis merah putus**: Route maintenance
## ✅ Success Criteria
**✅ BERHASIL** jika:
1. Item Pelanggan bisa dibuat dan muncul di peta
2. Routing berfungsi (garis muncul antara 2 marker)
3. Statistik update otomatis
4. Drag & drop masih berfungsi
5. Edit/delete masih berfungsi
**❌ GAGAL** jika:
1. Error JavaScript di console
2. Marker tidak muncul setelah simpan
3. Routing sama sekali tidak ada response
4. Database error di API calls
Silakan test sesuai checklist di atas dan laporkan hasil atau error yang ditemukan! 🚀

8
update_database.sql Normal file
View File

@ -0,0 +1,8 @@
-- Script untuk menambahkan item type Pelanggan ke database yang sudah ada
-- Jalankan script ini jika database sudah dibuat sebelumnya
-- Tambah item type Pelanggan jika belum ada
INSERT IGNORE INTO item_types (id, name, icon, color) VALUES (5, 'Pelanggan', 'fas fa-home', '#FFA500');
-- Cek hasil
SELECT * FROM item_types;

BIN
wifi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB