mirror of
https://github.com/mivodev/mivo.git
synced 2026-01-26 05:25:42 +07:00
chore: bump version to v1.2.0, cleanup repo, and update docs refs
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
APP_NAME=MIVO
|
APP_NAME=MIVO
|
||||||
APP_ENV=production
|
APP_ENV=production
|
||||||
APP_KEY=mikhmonv3remake_secret_key_32bytes
|
APP_KEY=mivo_official_secret_key_32bytes
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
|
|||||||
9
.github/release_template.md
vendored
Normal file
9
.github/release_template.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
MIVO is a Modern, Lightweight, and Efficient. Built for low-end devices with premium UX.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
For the best experience, we recommend using **Docker**.
|
||||||
|
[Read the full Docker Installation Guide](https://mivodev.github.io/docs/guide/docker)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Ensure your server runs **PHP 8.0+** with `sqlite3` extension enabled.
|
||||||
|
- Default installation will guide you to create an Admin account.
|
||||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -60,6 +60,7 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
files: mivo-v${{ steps.get_version.outputs.VERSION }}.zip
|
files: mivo-v${{ steps.get_version.outputs.VERSION }}.zip
|
||||||
|
body_path: .github/release_template.md
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -22,10 +22,6 @@ Thumbs.db
|
|||||||
# Secrets and Environment
|
# Secrets and Environment
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# VitePress
|
|
||||||
docs/.vitepress/dist
|
|
||||||
docs/.vitepress/cache
|
|
||||||
|
|
||||||
# Build Scripts & Artifacts
|
# Build Scripts & Artifacts
|
||||||
build_release.ps1
|
build_release.ps1
|
||||||
deploy.ps1
|
deploy.ps1
|
||||||
@@ -33,3 +29,6 @@ deploy.ps1
|
|||||||
# User Uploads
|
# User Uploads
|
||||||
/public/uploads/*
|
/public/uploads/*
|
||||||
!/public/uploads/.gitignore
|
!/public/uploads/.gitignore
|
||||||
|
|
||||||
|
# Plugins
|
||||||
|
/plugins/*
|
||||||
@@ -24,7 +24,7 @@ docker run -d \
|
|||||||
-e APP_ENV=production \
|
-e APP_ENV=production \
|
||||||
-v mivo_data:/var/www/html/app/Database \
|
-v mivo_data:/var/www/html/app/Database \
|
||||||
-v mivo_config:/var/www/html/.env \
|
-v mivo_config:/var/www/html/.env \
|
||||||
ghcr.io/mivodev/mivo:latest
|
mivodev/mivo:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Open your browser and navigate to `http://localhost:8080`.
|
Open your browser and navigate to `http://localhost:8080`.
|
||||||
@@ -39,7 +39,7 @@ For a more permanent setup, use `docker-compose.yml`:
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
mivo:
|
mivo:
|
||||||
image: ghcr.io/mivodev/mivo:latest
|
image: mivodev/mivo:latest
|
||||||
container_name: mivo
|
container_name: mivo
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ MIVO is a next-generation **Mikrotik Voucher Management System** with a modern M
|
|||||||
|
|
||||||
> **Alternative (Docker):**
|
> **Alternative (Docker):**
|
||||||
> ```bash
|
> ```bash
|
||||||
> docker pull ghcr.io/mivodev/mivo
|
> docker pull mivodev/mivo
|
||||||
> ```
|
> ```
|
||||||
> *See [INSTALLATION.md](docs/INSTALLATION.md) for more tags.*
|
> *See [DOCKER_README.md](DOCKER_README.md) for more tags.*
|
||||||
|
|
||||||
2. **Setup Environment**
|
2. **Setup Environment**
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ namespace App\Config;
|
|||||||
|
|
||||||
class SiteConfig {
|
class SiteConfig {
|
||||||
const APP_NAME = 'MIVO';
|
const APP_NAME = 'MIVO';
|
||||||
const APP_VERSION = 'v1.1.1';
|
const APP_VERSION = 'v1.2.0';
|
||||||
const APP_FULL_NAME = 'MIVO - Mikrotik Voucher';
|
const APP_FULL_NAME = 'MIVO - Mikrotik Voucher';
|
||||||
const CREDIT_NAME = 'MivoDev';
|
const CREDIT_NAME = 'MivoDev';
|
||||||
const CREDIT_URL = 'https://github.com/mivodev';
|
const CREDIT_URL = 'https://github.com/mivodev';
|
||||||
@@ -13,7 +13,7 @@ class SiteConfig {
|
|||||||
// Security Keys
|
// Security Keys
|
||||||
// Fetched from .env or fallback to default
|
// Fetched from .env or fallback to default
|
||||||
public static function getSecretKey() {
|
public static function getSecretKey() {
|
||||||
return getenv('APP_KEY') ?: 'mikhmonv3remake_secret_key_32bytes';
|
return getenv('APP_KEY') ?: 'mivo_official_secret_key_32bytes';
|
||||||
}
|
}
|
||||||
|
|
||||||
const IS_DEV = true; // Still useful for code logic not relying on env yet, or can be refactored too.
|
const IS_DEV = true; // Still useful for code logic not relying on env yet, or can be refactored too.
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class InstallController extends Controller {
|
|||||||
Migrations::up();
|
Migrations::up();
|
||||||
|
|
||||||
// 2. Generate Key if default
|
// 2. Generate Key if default
|
||||||
if (SiteConfig::getSecretKey() === 'mikhmonv3remake_secret_key_32bytes') {
|
if (SiteConfig::getSecretKey() === 'mivo_official_secret_key_32bytes') {
|
||||||
$this->generateKey();
|
$this->generateKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,11 +90,11 @@ class InstallController extends Controller {
|
|||||||
$envPath = ROOT . '/.env';
|
$envPath = ROOT . '/.env';
|
||||||
if (!file_exists($envPath)) {
|
if (!file_exists($envPath)) {
|
||||||
// Check if SiteConfig has a manual override (legacy)
|
// Check if SiteConfig has a manual override (legacy)
|
||||||
return SiteConfig::getSecretKey() !== 'mikhmonv3remake_secret_key_32bytes';
|
return SiteConfig::getSecretKey() !== 'mivo_official_secret_key_32bytes';
|
||||||
}
|
}
|
||||||
|
|
||||||
$key = getenv('APP_KEY');
|
$key = getenv('APP_KEY');
|
||||||
$keyChanged = ($key && $key !== 'mikhmonv3remake_secret_key_32bytes');
|
$keyChanged = ($key && $key !== 'mivo_official_secret_key_32bytes');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
|
|||||||
@@ -191,10 +191,10 @@ class QuickPrintController extends Controller {
|
|||||||
// Check if M or G
|
// Check if M or G
|
||||||
// Simple logic for now, assuming raw if number, or passing string if Mikrotik accepts it (usually requires bytes)
|
// Simple logic for now, assuming raw if number, or passing string if Mikrotik accepts it (usually requires bytes)
|
||||||
// Let's assume user inputs "100M" or "1G" which usually needs parsing.
|
// Let's assume user inputs "100M" or "1G" which usually needs parsing.
|
||||||
// For now, let's assume input is NUMBER in MB as per standard Mikhmon practice, OR generic string.
|
// For now, let's assume input is NUMBER in MB as per standard Mivo practice, OR generic string.
|
||||||
// We'll pass as is for strings, or multiply if strictly numeric?
|
// We'll pass as is for strings, or multiply if strictly numeric?
|
||||||
// Let's rely on standard Mikrotik parsing if string passed, or convert.
|
// Let's rely on standard Mikrotik parsing if string passed, or convert.
|
||||||
// Mikhmon v3 usually uses dropdown "MB/GB".
|
// Mivo usually uses dropdown "MB/GB".
|
||||||
// Implementing simple conversion:
|
// Implementing simple conversion:
|
||||||
$val = intval($package['data_limit']);
|
$val = intval($package['data_limit']);
|
||||||
if (strpos(strtolower($package['data_limit']), 'g') !== false) {
|
if (strpos(strtolower($package['data_limit']), 'g') !== false) {
|
||||||
|
|||||||
@@ -11,44 +11,89 @@ class ReportController extends Controller
|
|||||||
{
|
{
|
||||||
public function index($session)
|
public function index($session)
|
||||||
{
|
{
|
||||||
$configModel = new Config();
|
$data = $this->getSellingReportData($session);
|
||||||
$config = $configModel->getSession($session);
|
if (!$data) {
|
||||||
|
|
||||||
if (!$config) {
|
|
||||||
header('Location: /');
|
header('Location: /');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->view('reports/selling', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sellingExport($session, $type)
|
||||||
|
{
|
||||||
|
$data = $this->getSellingReportData($session);
|
||||||
|
if (!$data) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => 'No data found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = $data['report'];
|
||||||
|
$exportData = [];
|
||||||
|
|
||||||
|
foreach ($report as $row) {
|
||||||
|
$exportData[] = [
|
||||||
|
'Date/Batch' => $row['date'],
|
||||||
|
'Status' => $row['status'] ?? '-',
|
||||||
|
'Qty (Stock)' => $row['count'],
|
||||||
|
'Used' => $row['realized_count'],
|
||||||
|
'Realized Income' => $row['realized_total'],
|
||||||
|
'Total Stock' => $row['total']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($exportData);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSellingReportData($session)
|
||||||
|
{
|
||||||
|
$configModel = new Config();
|
||||||
|
$config = $configModel->getSession($session);
|
||||||
|
|
||||||
|
if (!$config) return null;
|
||||||
|
|
||||||
$API = new RouterOSAPI();
|
$API = new RouterOSAPI();
|
||||||
$users = [];
|
$users = [];
|
||||||
|
|
||||||
|
$profilePriceMap = [];
|
||||||
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
|
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
|
||||||
// Fetch All Users
|
|
||||||
// Optimized print: get .id, name, price, comment
|
|
||||||
$users = $API->comm("/ip/hotspot/user/print");
|
$users = $API->comm("/ip/hotspot/user/print");
|
||||||
|
$profiles = $API->comm("/ip/hotspot/user/profile/print");
|
||||||
$API->disconnect();
|
$API->disconnect();
|
||||||
|
|
||||||
|
// Build Price Map from Profile Scripts
|
||||||
|
foreach ($profiles as $p) {
|
||||||
|
$meta = HotspotHelper::parseProfileMetadata($p['on-login'] ?? '');
|
||||||
|
if (!empty($meta['price'])) {
|
||||||
|
$profilePriceMap[$p['name']] = intval($meta['price']);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aggregate Data
|
// Aggregate Data
|
||||||
$report = [];
|
$report = [];
|
||||||
$totalIncome = 0;
|
$totalIncome = 0;
|
||||||
$totalVouchers = 0;
|
$totalVouchers = 0;
|
||||||
|
|
||||||
|
// Realized (Used) Metrics
|
||||||
|
$totalRealizedIncome = 0;
|
||||||
|
$totalUsedVouchers = 0;
|
||||||
|
|
||||||
foreach ($users as $user) {
|
foreach ($users as $user) {
|
||||||
// Skip if no price
|
// Smart Price Detection
|
||||||
if (empty($user['price']) || $user['price'] == '0') continue;
|
$price = $this->detectPrice($user, $profilePriceMap);
|
||||||
|
if ($price <= 0) continue;
|
||||||
|
|
||||||
|
// Inject price back to user array for downstream logic
|
||||||
|
$user['price'] = $price;
|
||||||
|
|
||||||
// Determine Date from Comment
|
// Determine Date from Comment
|
||||||
// Mikhmon format usually: "mikhmon-MM/DD/YYYY" or just "MM/DD/YYYY" or plain comment
|
|
||||||
// We will try to parse a date from the comment, or use "Unknown Date"
|
|
||||||
$date = 'Unknown Date';
|
$date = 'Unknown Date';
|
||||||
$comment = $user['comment'] ?? '';
|
$comment = $user['comment'] ?? '';
|
||||||
|
|
||||||
// Regex for date patterns (d-m-Y or m/d/Y or Y-m-d)
|
|
||||||
// Simplify: Group by Comment content itself if it looks like a date/batch
|
|
||||||
// Or try to extract M-Y.
|
|
||||||
|
|
||||||
// For feature parity, Mikhmon often groups by the exact comment string as the "Batch/Date"
|
|
||||||
if (!empty($comment)) {
|
if (!empty($comment)) {
|
||||||
$date = $comment;
|
$date = $comment;
|
||||||
}
|
}
|
||||||
@@ -57,28 +102,59 @@ class ReportController extends Controller
|
|||||||
$report[$date] = [
|
$report[$date] = [
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'count' => 0,
|
'count' => 0,
|
||||||
'total' => 0
|
'total' => 0,
|
||||||
|
'realized_total' => 0,
|
||||||
|
'realized_count' => 0
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$price = intval($user['price']);
|
$price = intval($user['price']);
|
||||||
|
|
||||||
|
// Check if Used
|
||||||
|
// Criteria: uptime != 0s OR bytes-out > 0 OR bytes-in > 0
|
||||||
|
$isUsed = false;
|
||||||
|
if ((isset($user['uptime']) && $user['uptime'] != '0s') ||
|
||||||
|
(isset($user['bytes-out']) && $user['bytes-out'] > 0)) {
|
||||||
|
$isUsed = true;
|
||||||
|
}
|
||||||
|
|
||||||
$report[$date]['count']++;
|
$report[$date]['count']++;
|
||||||
$report[$date]['total'] += $price;
|
$report[$date]['total'] += $price;
|
||||||
|
|
||||||
$totalIncome += $price;
|
$totalIncome += $price;
|
||||||
$totalVouchers++;
|
$totalVouchers++;
|
||||||
|
|
||||||
|
if ($isUsed) {
|
||||||
|
$report[$date]['realized_count']++;
|
||||||
|
$report[$date]['realized_total'] += $price;
|
||||||
|
$totalRealizedIncome += $price;
|
||||||
|
$totalUsedVouchers++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate Status for each batch
|
||||||
|
foreach ($report as &$row) {
|
||||||
|
if ($row['realized_count'] === 0) {
|
||||||
|
$row['status'] = 'New';
|
||||||
|
} elseif ($row['realized_count'] >= $row['count']) {
|
||||||
|
$row['status'] = 'Sold Out';
|
||||||
|
} else {
|
||||||
|
$row['status'] = 'Selling';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
// Sort by key (Date/Comment) desc
|
// Sort by key (Date/Comment) desc
|
||||||
krsort($report);
|
krsort($report);
|
||||||
|
|
||||||
return $this->view('reports/selling', [
|
return [
|
||||||
'session' => $session,
|
'session' => $session,
|
||||||
'report' => $report,
|
'report' => $report,
|
||||||
'totalIncome' => $totalIncome,
|
'totalIncome' => $totalIncome,
|
||||||
'totalVouchers' => $totalVouchers,
|
'totalVouchers' => $totalVouchers,
|
||||||
|
'totalRealizedIncome' => $totalRealizedIncome,
|
||||||
|
'totalUsedVouchers' => $totalUsedVouchers,
|
||||||
'currency' => $config['currency'] ?? 'Rp'
|
'currency' => $config['currency'] ?? 'Rp'
|
||||||
]);
|
];
|
||||||
}
|
}
|
||||||
public function resume($session)
|
public function resume($session)
|
||||||
{
|
{
|
||||||
@@ -93,9 +169,18 @@ class ReportController extends Controller
|
|||||||
$API = new RouterOSAPI();
|
$API = new RouterOSAPI();
|
||||||
$users = [];
|
$users = [];
|
||||||
|
|
||||||
|
$profilePriceMap = [];
|
||||||
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
|
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
|
||||||
$users = $API->comm("/ip/hotspot/user/print");
|
$users = $API->comm("/ip/hotspot/user/print");
|
||||||
|
$profiles = $API->comm("/ip/hotspot/user/profile/print");
|
||||||
$API->disconnect();
|
$API->disconnect();
|
||||||
|
|
||||||
|
foreach ($profiles as $p) {
|
||||||
|
$meta = HotspotHelper::parseProfileMetadata($p['on-login'] ?? '');
|
||||||
|
if (!empty($meta['price'])) {
|
||||||
|
$profilePriceMap[$p['name']] = intval($meta['price']);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Aggregates
|
// Initialize Aggregates
|
||||||
@@ -103,28 +188,69 @@ class ReportController extends Controller
|
|||||||
$monthly = [];
|
$monthly = [];
|
||||||
$yearly = [];
|
$yearly = [];
|
||||||
$totalIncome = 0;
|
$totalIncome = 0;
|
||||||
|
|
||||||
|
// Realized Metrics for Resume?
|
||||||
|
// Usually Resume is just general financial overview.
|
||||||
|
// We'll stick to Stock for now unless requested, as Resume mimics Mikhmon's logic closer.
|
||||||
|
// Or we can just calculate standard revenue based on Stock if that's what user expects for "Resume",
|
||||||
|
// OR we can add Realized. Let's keep Resume simple first, focus on Selling Report.
|
||||||
|
|
||||||
foreach ($users as $user) {
|
foreach ($users as $user) {
|
||||||
if (empty($user['price']) || $user['price'] == '0') continue;
|
$price = $this->detectPrice($user, $profilePriceMap);
|
||||||
|
if ($price <= 0) continue;
|
||||||
|
|
||||||
// Try to parse Date from Comment (Mikhmon format: mikhmon-10/25/2023 or just 10/25/2023)
|
$user['price'] = $price;
|
||||||
|
|
||||||
|
// Try to parse Date from Comment
|
||||||
|
// Supported formats:
|
||||||
|
// - MM/DD/YYYY or MM.DD.YYYY (US)
|
||||||
|
// - DD-MM-YYYY (EU/ID)
|
||||||
|
// - YYYY-MM-DD (ISO)
|
||||||
|
// Regex explanations:
|
||||||
|
// 1. \b starts word boundary to avoid matching parts of batch IDs (e.g. 711-...)
|
||||||
|
// 2. We look for 3 groups of digits separated by / . or -
|
||||||
$comment = $user['comment'] ?? '';
|
$comment = $user['comment'] ?? '';
|
||||||
$dateObj = null;
|
$dateObj = null;
|
||||||
|
|
||||||
// Simple parser: try to find MM/DD/YYYY
|
if (preg_match('/\b(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{2,4})\b/', $comment, $matches)) {
|
||||||
if (preg_match('/(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{2,4})/', $comment, $matches)) {
|
// Heuristic: If 3rd part is year (4 digits or > 31), use it.
|
||||||
// Assuming MM/DD/YYYY based on typical Mikhmon, but could be DD-MM-YYYY
|
// If 1st part > 12, it's likely Day (DD-MM-YYYY).
|
||||||
// Let's standardise on checking valid date.
|
// Mivo Generator format often: MM.DD.YY or DD.MM.YY
|
||||||
// Standard Mikhmon V3 is MM/DD/YYYY.
|
|
||||||
$m = $matches[1];
|
$p1 = intval($matches[1]);
|
||||||
$d = $matches[2];
|
$p2 = intval($matches[2]);
|
||||||
$y = $matches[3];
|
$p3 = intval($matches[3]);
|
||||||
if (strlen($y) == 2) $y = '20' . $y;
|
|
||||||
$dateObj = \DateTime::createFromFormat('m/d/Y', "$m/$d/$y");
|
$year = $p3;
|
||||||
|
$month = $p1;
|
||||||
|
$day = $p2;
|
||||||
|
|
||||||
|
// Adjust 2-digit year
|
||||||
|
if ($year < 100) $year += 2000;
|
||||||
|
|
||||||
|
// Guess format
|
||||||
|
// If p1 > 12, it must be Day. (DD-MM-YYYY)
|
||||||
|
if ($p1 > 12) {
|
||||||
|
$day = $p1;
|
||||||
|
$month = $p2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date
|
||||||
|
if (checkdate($month, $day, $year)) {
|
||||||
|
$dateObj = (new \DateTime())->setDate($year, $month, $day);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for ISO YYYY-MM-DD
|
||||||
|
elseif (preg_match('/\b(\d{4})[\/.-](\d{1,2})[\/.-](\d{1,2})\b/', $comment, $matches)) {
|
||||||
|
if (checkdate($matches[2], $matches[3], $matches[1])) {
|
||||||
|
$dateObj = (new \DateTime())->setDate($matches[1], $matches[2], $matches[3]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: If no date found in comment, maybe created at?
|
// Fallback: If no date found -> "Unknown Date" in resume?
|
||||||
// Usually Mikhmon relies strictly on comment.
|
// Resume requires Month/Year keys. If we can't parse date, we can't add to daily/monthly.
|
||||||
|
// We'll skip or add to "Unknown"?
|
||||||
|
// Current logic skips if !$dateObj
|
||||||
if (!$dateObj) continue;
|
if (!$dateObj) continue;
|
||||||
|
|
||||||
$price = intval($user['price']);
|
$price = intval($user['price']);
|
||||||
@@ -162,4 +288,38 @@ class ReportController extends Controller
|
|||||||
'currency' => $config['currency'] ?? 'Rp'
|
'currency' => $config['currency'] ?? 'Rp'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart Price Detection Logic
|
||||||
|
* Hierarchy:
|
||||||
|
* 1. Comment Override (p:5000)
|
||||||
|
* 2. Profile Script (Standard Profile)
|
||||||
|
* 3. Profile Name Fallback (50K) -- REMOVED loose number matching to avoid garbage data
|
||||||
|
*/
|
||||||
|
private function detectPrice($user, $profileMap)
|
||||||
|
{
|
||||||
|
$comment = $user['comment'] ?? '';
|
||||||
|
|
||||||
|
// 1. Comment Override (p:5000 or price:5000)
|
||||||
|
// Updated: Added \b to prevent matching "up-123" as "p-123"
|
||||||
|
if (preg_match('/\b(?:p|price)[:-]\s*(\d+)/i', $comment, $matches)) {
|
||||||
|
return intval($matches[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Profile Script
|
||||||
|
$profile = $user['profile'] ?? 'default';
|
||||||
|
if (isset($profileMap[$profile])) {
|
||||||
|
return $profileMap[$profile];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback: Parse Profile Name (Strict "K" notation only)
|
||||||
|
// Matches "5K", "5k" -> 5000
|
||||||
|
if (preg_match('/(\d+)k\b/i', $profile, $m)) {
|
||||||
|
return intval($m[1]) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEPRECATED: Loose number matching caused garbage data (e.g. "up-311" -> 311)
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class SettingsController extends Controller {
|
|||||||
$db = \App\Core\Database::getInstance();
|
$db = \App\Core\Database::getInstance();
|
||||||
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
|
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
|
||||||
// Assuming we are updating the default 'admin' user or the currently logged in user
|
// Assuming we are updating the default 'admin' user or the currently logged in user
|
||||||
// Original Mikhmon usually has one main user. Let's update 'admin' for now.
|
// Original Mivo usually has one main user. Let's update 'admin' for now.
|
||||||
$db->query("UPDATE users SET password = ? WHERE username = 'admin'", [$hash]);
|
$db->query("UPDATE users SET password = ? WHERE username = 'admin'", [$hash]);
|
||||||
\App\Helpers\FlashHelper::set('success', 'toasts.password_updated', 'toasts.password_updated_desc', [], true);
|
\App\Helpers\FlashHelper::set('success', 'toasts.password_updated', 'toasts.password_updated_desc', [], true);
|
||||||
}
|
}
|
||||||
@@ -431,4 +431,194 @@ class SettingsController extends Controller {
|
|||||||
}
|
}
|
||||||
header('Location: /settings/api-cors');
|
header('Location: /settings/api-cors');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Plugin Management ---
|
||||||
|
|
||||||
|
public function plugins() {
|
||||||
|
$pluginManager = new \App\Core\PluginManager();
|
||||||
|
// Since PluginManager loads everything in constructor/loadPlugins,
|
||||||
|
// we can just scan the directory to list them and check status (implied active for now)
|
||||||
|
$pluginsDir = ROOT . '/plugins';
|
||||||
|
$plugins = [];
|
||||||
|
|
||||||
|
if (is_dir($pluginsDir)) {
|
||||||
|
$folders = scandir($pluginsDir);
|
||||||
|
foreach ($folders as $folder) {
|
||||||
|
if ($folder === '.' || $folder === '..') continue;
|
||||||
|
|
||||||
|
$path = $pluginsDir . '/' . $folder;
|
||||||
|
if (is_dir($path) && file_exists($path . '/plugin.php')) {
|
||||||
|
// Try to read header from plugin.php
|
||||||
|
$content = file_get_contents($path . '/plugin.php', false, null, 0, 1024); // Read first 1KB
|
||||||
|
preg_match('/Plugin Name:\s*(.*)$/mi', $content, $nameMatch);
|
||||||
|
preg_match('/Version:\s*(.*)$/mi', $content, $verMatch);
|
||||||
|
preg_match('/Description:\s*(.*)$/mi', $content, $descMatch);
|
||||||
|
preg_match('/Author:\s*(.*)$/mi', $content, $authMatch);
|
||||||
|
|
||||||
|
$plugins[] = [
|
||||||
|
'id' => $folder,
|
||||||
|
'name' => trim($nameMatch[1] ?? $folder),
|
||||||
|
'version' => trim($verMatch[1] ?? '1.0.0'),
|
||||||
|
'description' => trim($descMatch[1] ?? '-'),
|
||||||
|
'author' => trim($authMatch[1] ?? '-'),
|
||||||
|
'path' => $path
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->view('settings/plugins', ['plugins' => $plugins]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadPlugin() {
|
||||||
|
if (!isset($_FILES['plugin_file']) || $_FILES['plugin_file']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'toasts.no_file_selected', [], true);
|
||||||
|
header('Location: /settings/plugins');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $_FILES['plugin_file'];
|
||||||
|
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
if ($ext !== 'zip') {
|
||||||
|
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'Only .zip files are allowed', [], true);
|
||||||
|
header('Location: /settings/plugins');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip = new \ZipArchive();
|
||||||
|
if ($zip->open($file['tmp_name']) === TRUE) {
|
||||||
|
$extractPath = ROOT . '/plugins/';
|
||||||
|
if (!is_dir($extractPath)) mkdir($extractPath, 0755, true);
|
||||||
|
|
||||||
|
// TODO: Better validation to prevent overwriting existing plugins without confirmation?
|
||||||
|
// For now, extraction overwrites.
|
||||||
|
|
||||||
|
// Validate content before extracting everything
|
||||||
|
// Check if zip has a root folder or just files
|
||||||
|
// Logic:
|
||||||
|
// 1. Extract to temp.
|
||||||
|
// 2. Find plugin.php
|
||||||
|
// 3. Move to plugins dir.
|
||||||
|
|
||||||
|
$tempExtract = sys_get_temp_dir() . '/mivo_plugin_' . uniqid();
|
||||||
|
if (!mkdir($tempExtract, 0755, true)) {
|
||||||
|
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'Failed to create temp dir', [], true);
|
||||||
|
header('Location: /settings/plugins');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->extractTo($tempExtract);
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
// Search for plugin.php
|
||||||
|
$pluginFile = null;
|
||||||
|
$pluginRoot = $tempExtract;
|
||||||
|
|
||||||
|
// Recursive iterator to find plugin.php (max depth 2 to avoid deep scanning)
|
||||||
|
$rii = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tempExtract));
|
||||||
|
foreach ($rii as $file) {
|
||||||
|
if ($file->isDir()) continue;
|
||||||
|
if ($file->getFilename() === 'plugin.php') {
|
||||||
|
$pluginFile = $file->getPathname();
|
||||||
|
$pluginRoot = dirname($pluginFile);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pluginFile) {
|
||||||
|
// Determine destination name
|
||||||
|
// If the immediate parent of plugin.php is NOT the temp dir, use that folder name.
|
||||||
|
// Else use the zip name.
|
||||||
|
$folderName = basename($pluginRoot);
|
||||||
|
if ($pluginRoot === $tempExtract) {
|
||||||
|
$folderName = pathinfo($_FILES['plugin_file']['name'], PATHINFO_FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dest = $extractPath . $folderName;
|
||||||
|
|
||||||
|
// Move/Copy
|
||||||
|
// Using helper or rename. Rename might fail across volumes (temp to project).
|
||||||
|
// Use custom recursive copy then delete temp.
|
||||||
|
$this->recurseCopy($pluginRoot, $dest);
|
||||||
|
|
||||||
|
\App\Helpers\FlashHelper::set('success', 'toasts.plugin_installed', 'toasts.plugin_installed_desc', ['name' => $folderName], true);
|
||||||
|
} else {
|
||||||
|
\App\Helpers\FlashHelper::set('error', 'toasts.install_failed', 'toasts.invalid_plugin_desc', [], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
$this->recurseDelete($tempExtract);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'toasts.zip_open_failed_desc', [], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Location: /settings/plugins');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deletePlugin() {
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
header('Location: /settings/plugins');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $_POST['plugin_id'] ?? '';
|
||||||
|
if (empty($id)) {
|
||||||
|
\App\Helpers\FlashHelper::set('error', 'common.error', 'Invalid plugin ID', [], true);
|
||||||
|
header('Location: /settings/plugins');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security check: validate id is just a folder name, no path traversal
|
||||||
|
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $id)) {
|
||||||
|
\App\Helpers\FlashHelper::set('error', 'common.error', 'Invalid plugin ID format', [], true);
|
||||||
|
header('Location: /settings/plugins');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pluginDir = ROOT . '/plugins/' . $id;
|
||||||
|
|
||||||
|
if (is_dir($pluginDir)) {
|
||||||
|
$this->recurseDelete($pluginDir);
|
||||||
|
\App\Helpers\FlashHelper::set('success', 'toasts.plugin_deleted', 'toasts.plugin_deleted_desc', [], true);
|
||||||
|
} else {
|
||||||
|
\App\Helpers\FlashHelper::set('error', 'common.error', 'Plugin directory not found', [], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Location: /settings/plugins');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for recursive copy (since rename/move_uploaded_file limit across partitions)
|
||||||
|
private function recurseCopy($src, $dst) {
|
||||||
|
$dir = opendir($src);
|
||||||
|
@mkdir($dst);
|
||||||
|
while(false !== ( $file = readdir($dir)) ) {
|
||||||
|
if (( $file != '.' ) && ( $file != '..' )) {
|
||||||
|
if ( is_dir($src . '/' . $file) ) {
|
||||||
|
$this->recurseCopy($src . '/' . $file,$dst . '/' . $file);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
copy($src . '/' . $file,$dst . '/' . $file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closedir($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recurseDelete($dir) {
|
||||||
|
if (!is_dir($dir)) return;
|
||||||
|
$scan = scandir($dir);
|
||||||
|
foreach ($scan as $file) {
|
||||||
|
if ($file == '.' || $file == '..') continue;
|
||||||
|
if (is_dir($dir . "/" . $file)) {
|
||||||
|
$this->recurseDelete($dir . "/" . $file);
|
||||||
|
} else {
|
||||||
|
unlink($dir . "/" . $file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class Console {
|
|||||||
|
|
||||||
private function printBanner() {
|
private function printBanner() {
|
||||||
echo "\n";
|
echo "\n";
|
||||||
echo self::COLOR_BOLD . " MIVO Helper " . self::COLOR_RESET . self::COLOR_GRAY . "v1.1.0" . self::COLOR_RESET . "\n\n";
|
echo self::COLOR_BOLD . " MIVO Helper " . self::COLOR_RESET . self::COLOR_GRAY . \App\Config\SiteConfig::APP_VERSION . self::COLOR_RESET . "\n\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
private function commandServe($args) {
|
private function commandServe($args) {
|
||||||
@@ -171,7 +171,7 @@ class Console {
|
|||||||
|
|
||||||
if (file_exists($envPath)) {
|
if (file_exists($envPath)) {
|
||||||
$envIds = parse_ini_file($envPath);
|
$envIds = parse_ini_file($envPath);
|
||||||
if (!empty($envIds['APP_KEY']) && $envIds['APP_KEY'] !== 'mikhmonv3remake_secret_key_32bytes') {
|
if (!empty($envIds['APP_KEY']) && $envIds['APP_KEY'] !== 'mivo_official_secret_key_32bytes') {
|
||||||
$keyExists = true;
|
$keyExists = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace App\Helpers;
|
|||||||
class HotspotHelper
|
class HotspotHelper
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Parse profile on-login script metadata (Mikhmon format)
|
* Parse profile on-login script metadata (Standard format)
|
||||||
* Format: :put (",mode,price,validity,selling_price,lock_user,")
|
* Format: :put (",mode,price,validity,selling_price,lock_user,")
|
||||||
*/
|
*/
|
||||||
public static function parseProfileMetadata($script) {
|
public static function parseProfileMetadata($script) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class TemplateHelper {
|
|||||||
'{{ip_address}}' => '192.168.88.254',
|
'{{ip_address}}' => '192.168.88.254',
|
||||||
'{{mac_address}}' => 'AA:BB:CC:DD:EE:FF',
|
'{{mac_address}}' => 'AA:BB:CC:DD:EE:FF',
|
||||||
'{{comment}}' => 'Thank You',
|
'{{comment}}' => 'Thank You',
|
||||||
'{{copyright}}' => 'Mikhmon',
|
'{{copyright}}' => 'Mivo',
|
||||||
];
|
];
|
||||||
|
|
||||||
$content = str_replace(array_keys($dummyData), array_values($dummyData), $content);
|
$content = str_replace(array_keys($dummyData), array_values($dummyData), $content);
|
||||||
|
|||||||
@@ -326,6 +326,30 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
setInterval(fetchTraffic, reloadInterval);
|
setInterval(fetchTraffic, reloadInterval);
|
||||||
fetchTraffic();
|
fetchTraffic();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Localization Support
|
||||||
|
const updateChartLabels = () => {
|
||||||
|
if (window.i18n && window.i18n.isLoaded) {
|
||||||
|
const rxLabel = window.i18n.t('dashboard.rx_download');
|
||||||
|
const txLabel = window.i18n.t('dashboard.tx_upload');
|
||||||
|
|
||||||
|
// Only update if changed
|
||||||
|
if (chart.data.datasets[0].label !== rxLabel || chart.data.datasets[1].label !== txLabel) {
|
||||||
|
chart.data.datasets[0].label = rxLabel;
|
||||||
|
chart.data.datasets[1].label = txLabel;
|
||||||
|
chart.update('none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for language changes
|
||||||
|
if (window.Mivo) {
|
||||||
|
window.Mivo.on('languageChanged', updateChartLabels);
|
||||||
|
}
|
||||||
|
window.addEventListener('languageChanged', updateChartLabels);
|
||||||
|
|
||||||
|
// Try initial update after a short delay to ensure i18n is ready if race condition
|
||||||
|
setTimeout(updateChartLabels, 500);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="flex items-center justify-between mb-8">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold tracking-tight">Design System</h1>
|
<h1 class="text-3xl font-bold tracking-tight">Design System</h1>
|
||||||
<p class="text-accents-5">Component library and style guide for Mikhmon v3.</p>
|
<p class="text-accents-5">Component library and style guide for Mivo.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button onclick="document.documentElement.classList.remove('dark')" class="btn bg-gray-200 text-gray-800">Light</button>
|
<button onclick="document.documentElement.classList.remove('dark')" class="btn bg-gray-200 text-gray-800">Light</button>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<div class="w-full max-w-4xl mx-auto py-8 md:py-16 px-4 sm:px-6 text-center">
|
<div class="w-full max-w-4xl mx-auto py-8 md:py-16 px-4 sm:px-6 text-center">
|
||||||
<div class="mb-8 flex justify-center">
|
<div class="mb-8 flex justify-center">
|
||||||
<div class="h-16 w-16 bg-transparent rounded-full flex items-center justify-center">
|
<div class="h-16 w-16 bg-transparent rounded-full flex items-center justify-center">
|
||||||
<img src="/assets/img/logo-m.svg" alt="Mikhmon Logo" class="h-16 w-auto block dark:hidden">
|
<img src="/assets/img/logo-m.svg" alt="Mivo Logo" class="h-16 w-auto block dark:hidden">
|
||||||
<img src="/assets/img/logo-m-dark.svg" alt="Mikhmon Logo" class="h-16 w-auto hidden dark:block">
|
<img src="/assets/img/logo-m-dark.svg" alt="Mivo Logo" class="h-16 w-auto hidden dark:block">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ $menu = [
|
|||||||
['label' => 'templates_title', 'url' => '/settings/voucher-templates', 'namespace' => 'settings'],
|
['label' => 'templates_title', 'url' => '/settings/voucher-templates', 'namespace' => 'settings'],
|
||||||
['label' => 'logos_title', 'url' => '/settings/logos', 'namespace' => 'settings'],
|
['label' => 'logos_title', 'url' => '/settings/logos', 'namespace' => 'settings'],
|
||||||
['label' => 'api_cors_title', 'url' => '/settings/api-cors', 'namespace' => 'settings'],
|
['label' => 'api_cors_title', 'url' => '/settings/api-cors', 'namespace' => 'settings'],
|
||||||
|
['label' => 'plugins_title', 'url' => '/settings/plugins', 'namespace' => 'settings'],
|
||||||
];
|
];
|
||||||
?>
|
?>
|
||||||
<nav id="settings-sidebar" class="w-full sticky top-[64px] z-40 bg-background/95 backdrop-blur border-b border-accents-2 transition-all duration-300">
|
<nav id="settings-sidebar" class="w-full sticky top-[64px] z-40 bg-background/95 backdrop-blur border-b border-accents-2 transition-all duration-300">
|
||||||
|
|||||||
@@ -26,88 +26,92 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
|
|
||||||
<!-- Daily Tab -->
|
<!-- Daily Tab -->
|
||||||
<div id="content-daily" class="tab-content">
|
<div id="content-daily" class="tab-content">
|
||||||
<div class="table-container">
|
<table class="table-glass" id="table-daily">
|
||||||
<table class="table-glass">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th data-i18n="reports.date">Date</th>
|
||||||
<th data-i18n="reports.date">Date</th>
|
<th class="text-right" data-i18n="reports.total">Total</th>
|
||||||
<th class="text-right" data-i18n="reports.total">Total</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
<?php foreach ($daily as $date => $total): ?>
|
||||||
<?php foreach ($daily as $date => $total): ?>
|
<tr>
|
||||||
<tr>
|
<td><?= $date ?></td>
|
||||||
<td><?= $date ?></td>
|
<td class="text-right font-mono"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
|
||||||
<td class="text-right"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
|
</tr>
|
||||||
</tr>
|
<?php endforeach; ?>
|
||||||
<?php endforeach; ?>
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Monthly Tab -->
|
<!-- Monthly Tab -->
|
||||||
<div id="content-monthly" class="tab-content hidden">
|
<div id="content-monthly" class="tab-content hidden">
|
||||||
<div class="table-container">
|
<table class="table-glass" id="table-monthly">
|
||||||
<table class="table-glass">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th data-i18n="reports.month">Month</th>
|
||||||
<th data-i18n="reports.month">Month</th>
|
<th class="text-right" data-i18n="reports.total">Total</th>
|
||||||
<th class="text-right" data-i18n="reports.total">Total</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
<?php foreach ($monthly as $date => $total): ?>
|
||||||
<?php foreach ($monthly as $date => $total): ?>
|
<tr>
|
||||||
<tr>
|
<td><?= $date ?></td>
|
||||||
<td><?= $date ?></td>
|
<td class="text-right font-mono"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
|
||||||
<td class="text-right"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
|
</tr>
|
||||||
</tr>
|
<?php endforeach; ?>
|
||||||
<?php endforeach; ?>
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Yearly Tab -->
|
<!-- Yearly Tab -->
|
||||||
<div id="content-yearly" class="tab-content hidden">
|
<div id="content-yearly" class="tab-content hidden">
|
||||||
<div class="table-container">
|
<table class="table-glass" id="table-yearly">
|
||||||
<table class="table-glass">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th data-i18n="reports.year">Year</th>
|
||||||
<th data-i18n="reports.year">Year</th>
|
<th class="text-right" data-i18n="reports.total">Total</th>
|
||||||
<th class="text-right" data-i18n="reports.total">Total</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
<?php foreach ($yearly as $date => $total): ?>
|
||||||
<?php foreach ($yearly as $date => $total): ?>
|
<tr>
|
||||||
<tr>
|
<td><?= $date ?></td>
|
||||||
<td><?= $date ?></td>
|
<td class="text-right font-mono"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
|
||||||
<td class="text-right"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
|
</tr>
|
||||||
</tr>
|
<?php endforeach; ?>
|
||||||
<?php endforeach; ?>
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/assets/js/components/datatable.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function switchTab(tabName) {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Hide all contents
|
// Init Datatables
|
||||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
if (typeof SimpleDataTable !== 'undefined') {
|
||||||
// Show selected
|
new SimpleDataTable('#table-daily', { itemsPerPage: 10, searchable: true });
|
||||||
document.getElementById('content-' + tabName).classList.remove('hidden');
|
new SimpleDataTable('#table-monthly', { itemsPerPage: 10, searchable: true });
|
||||||
|
new SimpleDataTable('#table-yearly', { itemsPerPage: 10, searchable: true });
|
||||||
// Reset tab styles
|
}
|
||||||
document.querySelectorAll('nav button').forEach(el => {
|
|
||||||
el.classList.remove('border-primary', 'text-primary');
|
|
||||||
el.classList.add('border-transparent', 'text-accents-5');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Active tab style
|
function switchTab(tabName) {
|
||||||
const btn = document.getElementById('tab-' + tabName);
|
// Hide all contents
|
||||||
btn.classList.remove('border-transparent', 'text-accents-5');
|
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||||
btn.classList.add('border-primary', 'text-primary');
|
// Show selected
|
||||||
}
|
document.getElementById('content-' + tabName).classList.remove('hidden');
|
||||||
|
|
||||||
|
// Reset tab styles
|
||||||
|
document.querySelectorAll('nav button').forEach(el => {
|
||||||
|
el.classList.remove('border-primary', 'text-primary');
|
||||||
|
el.classList.add('border-transparent', 'text-accents-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Active tab style
|
||||||
|
const btn = document.getElementById('tab-' + tabName);
|
||||||
|
btn.classList.remove('border-transparent', 'text-accents-5');
|
||||||
|
btn.classList.add('border-primary', 'text-primary');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||||
|
|||||||
@@ -9,197 +9,182 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
<p class="text-accents-5"><span data-i18n="reports.selling_subtitle">Sales summary and details for:</span> <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
|
<p class="text-accents-5"><span data-i18n="reports.selling_subtitle">Sales summary and details for:</span> <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button onclick="location.reload()" class="btn btn-secondary">
|
<div class="dropdown dropdown-end relative" id="export-dropdown">
|
||||||
|
<button class="btn btn-secondary dropdown-toggle" onclick="document.getElementById('export-menu').classList.toggle('hidden')">
|
||||||
|
<i data-lucide="download" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.export">Export</span>
|
||||||
|
<i data-lucide="chevron-down" class="w-4 h-4 ml-1"></i>
|
||||||
|
</button>
|
||||||
|
<div id="export-menu" class="dropdown-menu hidden absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white dark:bg-black border border-accents-2 z-50 p-1">
|
||||||
|
<button onclick="exportReport('csv')" class="block w-full text-left px-4 py-2 text-sm text-foreground hover:bg-accents-1 rounded flex items-center">
|
||||||
|
<i data-lucide="file-text" class="w-4 h-4 mr-2 text-green-600"></i> Export CSV
|
||||||
|
</button>
|
||||||
|
<button onclick="exportReport('xlsx')" class="block w-full text-left px-4 py-2 text-sm text-foreground hover:bg-accents-1 rounded flex items-center">
|
||||||
|
<i data-lucide="sheet" class="w-4 h-4 mr-2 text-green-600"></i> Export Excel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="location.reload()" class="btn btn-secondary">
|
||||||
<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.refresh">Refresh</span>
|
<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.refresh">Refresh</span>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="window.print()" class="btn btn-primary">
|
<button onclick="window.print()" class="btn btn-primary">
|
||||||
<i data-lucide="printer" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.print_report">Print Report</span>
|
<i data-lucide="printer" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.print_report">Print Report</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary Cards -->
|
<!-- Summary Cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||||
<div class="card bg-accents-1 border-accents-2">
|
<!-- Stock / Potential -->
|
||||||
<div class="text-sm text-accents-5 uppercase font-bold tracking-wide" data-i18n="reports.total_income">Total Income</div>
|
<div class="card">
|
||||||
<div class="text-3xl font-bold text-green-500 mt-2">
|
<div class="text-sm text-accents-5 uppercase font-bold tracking-wide" data-i18n="reports.generated_stock">Generated Stock</div>
|
||||||
|
<div class="text-3xl font-bold text-accents-6 mt-2">
|
||||||
<?= \App\Helpers\FormatHelper::formatCurrency($totalIncome, $currency) ?>
|
<?= \App\Helpers\FormatHelper::formatCurrency($totalIncome, $currency) ?>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-xs text-accents-5 mt-1">
|
||||||
|
<?= number_format($totalVouchers) ?> vouchers
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card bg-accents-1 border-accents-2">
|
|
||||||
<div class="text-sm text-accents-5 uppercase font-bold tracking-wide" data-i18n="reports.total_vouchers">Total Vouchers Sold</div>
|
<!-- Realized / Actual -->
|
||||||
<div class="text-3xl font-bold text-blue-500 mt-2">
|
<div class="card !bg-green-500/10 !border-green-500/20">
|
||||||
<?= number_format($totalVouchers, 0, ',', '.') ?>
|
<div class="text-sm text-green-600 dark:text-green-400 uppercase font-bold tracking-wide" data-i18n="reports.realized_income">Realized Income</div>
|
||||||
|
<div class="text-3xl font-bold text-green-600 dark:text-green-400 mt-2">
|
||||||
|
<?= \App\Helpers\FormatHelper::formatCurrency($totalRealizedIncome ?? 0, $currency) ?>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-green-600/70 dark:text-green-400/70 mt-1">
|
||||||
|
<?= number_format($totalUsedVouchers ?? 0) ?> used
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Filter Bar -->
|
|
||||||
<div class="flex flex-col md:flex-row gap-4 justify-between items-center no-print">
|
|
||||||
<!-- Search -->
|
|
||||||
<div class="relative w-full md:w-64">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i data-lucide="search" class="h-4 w-4 text-accents-5"></i>
|
|
||||||
</div>
|
|
||||||
<input type="text" id="global-search" class="form-input pl-10 w-full" placeholder="Search date..." data-i18n-placeholder="common.table.search_placeholder">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Detailed Table -->
|
<!-- Detailed Table -->
|
||||||
<div class="table-container">
|
<table class="table-glass" id="report-table">
|
||||||
<table class="table-glass" id="report-table">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
|
<th data-sort="date" data-i18n="reports.date_batch">Date / Batch (Comment)</th>
|
||||||
|
<th data-i18n="reports.status">Status</th>
|
||||||
|
<th class="text-right" data-i18n="reports.qty">Qty (Stock)</th>
|
||||||
|
<th class="text-right text-green-500" data-i18n="reports.used">Used</th>
|
||||||
|
<th data-sort="total" class="text-right" data-i18n="reports.total_stock">Total Stock</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="table-body">
|
||||||
|
<?php if (empty($report)): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<th data-sort="date" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="reports.date_batch">Date / Batch (Comment)</th>
|
<td colspan="5" class="p-8 text-center text-accents-5" data-i18n="reports.no_data">No sales data found.</td>
|
||||||
<th class="text-right" data-i18n="reports.qty">Qty</th>
|
|
||||||
<th data-sort="total" class="sortable text-right cursor-pointer hover:text-foreground select-none" data-i18n="reports.total">Total</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
<?php else: ?>
|
||||||
<tbody id="table-body">
|
<?php foreach ($report as $row): ?>
|
||||||
<?php if (empty($report)): ?>
|
<tr class="table-row-item">
|
||||||
<tr>
|
<td class="font-medium">
|
||||||
<td colspan="3" class="p-8 text-center text-accents-5" data-i18n="reports.no_data">No sales data found.</td>
|
<?= htmlspecialchars($row['date']) ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php if($row['status'] === 'New'): ?>
|
||||||
|
<span class="px-2 py-1 text-xs font-bold rounded-md bg-accents-2 text-accents-6">NEW</span>
|
||||||
|
<?php elseif($row['status'] === 'Selling'): ?>
|
||||||
|
<span class="px-2 py-1 text-xs font-bold rounded-md bg-blue-500/10 text-blue-500 border border-blue-500/20">SELLING</span>
|
||||||
|
<?php elseif($row['status'] === 'Sold Out'): ?>
|
||||||
|
<span class="px-2 py-1 text-xs font-bold rounded-md bg-green-500/10 text-green-500 border border-green-500/20">SOLD OUT</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td class="text-right font-mono text-accents-6">
|
||||||
|
<?= number_format($row['count']) ?>
|
||||||
|
</td>
|
||||||
|
<td class="text-right font-mono text-green-500 font-medium">
|
||||||
|
<?= number_format($row['realized_count']) ?>
|
||||||
|
<span class="text-xs opacity-70 block">
|
||||||
|
<?= \App\Helpers\FormatHelper::formatCurrency($row['realized_total'], $currency) ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right font-mono font-bold text-foreground">
|
||||||
|
<?= \App\Helpers\FormatHelper::formatCurrency($row['total'], $currency) ?>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php else: ?>
|
<?php endforeach; ?>
|
||||||
<?php foreach ($report as $row): ?>
|
<?php endif; ?>
|
||||||
<tr class="table-row-item"
|
</tbody>
|
||||||
data-date="<?= strtolower($row['date']) ?>"
|
</table>
|
||||||
data-total="<?= $row['total'] ?>">
|
|
||||||
<td class="font-medium"><?= htmlspecialchars($row['date']) ?></td>
|
|
||||||
<td class="text-right font-mono"><?= number_format($row['count']) ?></td>
|
|
||||||
<td class="text-right font-mono font-bold text-foreground">
|
|
||||||
<?= \App\Helpers\FormatHelper::formatCurrency($row['total'], $currency) ?>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<div class="px-6 py-4 border-t border-white/10 flex items-center justify-between no-print" id="pagination-controls">
|
|
||||||
<div class="text-sm text-accents-5">
|
|
||||||
Showing <span id="start-idx" class="font-medium text-foreground">0</span> to <span id="end-idx" class="font-medium text-foreground">0</span> of <span id="total-count" class="font-medium text-foreground">0</span> rows
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button id="prev-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.previous">Previous</button>
|
|
||||||
<div id="page-numbers" class="flex gap-1"></div>
|
|
||||||
<button id="next-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.next">Next</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<script src="/assets/js/components/datatable.js"></script>
|
||||||
|
<!-- Local SheetJS Library -->
|
||||||
|
<script src="/assets/vendor/xlsx/xlsx.full.min.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
class TableManager {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
constructor(rows, itemsPerPage = 15) {
|
if (typeof SimpleDataTable !== 'undefined') {
|
||||||
this.allRows = Array.from(rows);
|
new SimpleDataTable('#report-table', {
|
||||||
this.filteredRows = this.allRows;
|
itemsPerPage: 15,
|
||||||
this.itemsPerPage = itemsPerPage;
|
searchable: true,
|
||||||
this.currentPage = 1;
|
pagination: true,
|
||||||
|
// Add Filter for Status Column (Index 1)
|
||||||
this.elements = {
|
filters: [
|
||||||
body: document.getElementById('table-body'),
|
{ index: 1, label: 'Status: All' }
|
||||||
startIdx: document.getElementById('start-idx'),
|
]
|
||||||
endIdx: document.getElementById('end-idx'),
|
|
||||||
totalCount: document.getElementById('total-count'),
|
|
||||||
prevBtn: document.getElementById('prev-btn'),
|
|
||||||
nextBtn: document.getElementById('next-btn'),
|
|
||||||
pageNumbers: document.getElementById('page-numbers')
|
|
||||||
};
|
|
||||||
|
|
||||||
this.filters = { search: '' };
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Translate placeholder
|
|
||||||
const searchInput = document.getElementById('global-search');
|
|
||||||
if (searchInput && window.i18n) {
|
|
||||||
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
|
|
||||||
}
|
|
||||||
document.getElementById('global-search').addEventListener('input', (e) => {
|
|
||||||
this.filters.search = e.target.value.toLowerCase();
|
|
||||||
this.currentPage = 1;
|
|
||||||
this.update();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.elements.prevBtn.addEventListener('click', () => { if(this.currentPage > 1) { this.currentPage--; this.render(); } });
|
|
||||||
this.elements.nextBtn.addEventListener('click', () => {
|
|
||||||
const max = Math.ceil(this.filteredRows.length / this.itemsPerPage);
|
|
||||||
if(this.currentPage < max) { this.currentPage++; this.render(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
this.update();
|
|
||||||
|
|
||||||
// Listen for language change
|
|
||||||
window.addEventListener('languageChanged', () => {
|
|
||||||
const searchInput = document.getElementById('global-search');
|
|
||||||
if (searchInput && window.i18n) {
|
|
||||||
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
|
|
||||||
}
|
|
||||||
this.render();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
update() {
|
async function exportReport(type) {
|
||||||
this.filteredRows = this.allRows.filter(row => {
|
const url = '/<?= $session ?>/reports/selling/export/' + type;
|
||||||
const date = row.dataset.date || '';
|
const btn = document.querySelector('.dropdown-toggle');
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
if (this.filters.search && !date.includes(this.filters.search)) return false;
|
|
||||||
|
// Show Loading State
|
||||||
return true;
|
btn.innerHTML = `<i data-lucide="loader-2" class="w-4 h-4 mr-2 animate-spin"></i> Processing...`;
|
||||||
});
|
lucide.createIcons();
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
try {
|
||||||
const total = this.filteredRows.length;
|
const response = await fetch(url);
|
||||||
const maxPage = Math.ceil(total / this.itemsPerPage) || 1;
|
const data = await response.json();
|
||||||
if (this.currentPage > maxPage) this.currentPage = maxPage;
|
|
||||||
|
|
||||||
const start = (this.currentPage - 1) * this.itemsPerPage;
|
|
||||||
const end = Math.min(start + this.itemsPerPage, total);
|
|
||||||
|
|
||||||
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
|
|
||||||
this.elements.endIdx.textContent = end;
|
|
||||||
this.elements.totalCount.textContent = total;
|
|
||||||
|
|
||||||
// Update Text (Use Translation)
|
|
||||||
if (window.i18n && document.getElementById('pagination-controls')) {
|
|
||||||
const text = window.i18n.t('common.table.showing', {
|
|
||||||
start: total === 0 ? 0 : start + 1,
|
|
||||||
end: end,
|
|
||||||
total: total
|
|
||||||
});
|
|
||||||
// Find and update the text node if possible
|
|
||||||
const container = document.getElementById('pagination-controls').querySelector('.text-accents-5');
|
|
||||||
if(container) {
|
|
||||||
container.innerHTML = text.replace('{start}', `<span class="font-medium text-foreground">${total === 0 ? 0 : start + 1}</span>`)
|
|
||||||
.replace('{end}', `<span class="font-medium text-foreground">${end}</span>`)
|
|
||||||
.replace('{total}', `<span class="font-medium text-foreground">${total}</span>`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.elements.body.innerHTML = '';
|
|
||||||
this.filteredRows.slice(start, end).forEach(row => this.elements.body.appendChild(row));
|
|
||||||
|
|
||||||
this.elements.prevBtn.disabled = this.currentPage === 1;
|
|
||||||
this.elements.nextBtn.disabled = this.currentPage === maxPage || total === 0;
|
|
||||||
|
|
||||||
if (this.elements.pageNumbers) {
|
if (data.error) {
|
||||||
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: maxPage}) : `Page ${this.currentPage} of ${maxPage}`;
|
alert('Export Failed: ' + data.error);
|
||||||
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
const filename = `selling-report-<?= date('Y-m-d') ?>-${type}.` + (type === 'csv' ? 'csv' : 'xlsx');
|
||||||
|
|
||||||
|
if (type === 'csv') {
|
||||||
|
// Convert JSON to CSV
|
||||||
|
const header = Object.keys(data[0]);
|
||||||
|
const csv = [
|
||||||
|
header.join(','), // header row first
|
||||||
|
...data.map(row => header.map(fieldName => JSON.stringify(row[fieldName])).join(','))
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.setAttribute('hidden', '');
|
||||||
|
a.setAttribute('href', url);
|
||||||
|
a.setAttribute('download', filename);
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
else if (type === 'xlsx') {
|
||||||
|
// Use SheetJS for Real Excel
|
||||||
|
const ws = XLSX.utils.json_to_sheet(data);
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, "Selling Report");
|
||||||
|
XLSX.writeFile(wb, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export Error:', error);
|
||||||
|
alert('Failed to export data. Check console for details.');
|
||||||
|
} finally {
|
||||||
|
// Restore Button
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
lucide.createIcons();
|
||||||
|
document.getElementById('export-menu').classList.add('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
new TableManager(document.querySelectorAll('.table-row-item'), 15);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||||
|
|||||||
129
app/Views/settings/plugins.php
Normal file
129
app/Views/settings/plugins.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
// Plugins View
|
||||||
|
$title = "Plugins";
|
||||||
|
$no_main_container = true;
|
||||||
|
require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!-- Sub-Navbar Navigation -->
|
||||||
|
<?php include ROOT . '/app/Views/layouts/sidebar_settings.php'; ?>
|
||||||
|
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex-grow w-full flex flex-col">
|
||||||
|
|
||||||
|
<div class="mb-8 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight" data-i18n="settings.plugins">Plugins</h1>
|
||||||
|
<p class="text-accents-5 mt-2" data-i18n="settings.plugins_desc">Manage and extend functionality with plugins.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="openUploadModal()" class="btn btn-primary">
|
||||||
|
<i data-lucide="upload" class="w-4 h-4 mr-2"></i>
|
||||||
|
<span data-i18n="settings.upload_plugin">Upload Plugin</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Area -->
|
||||||
|
<div class="mt-8 flex-1 min-w-0" id="settings-content-area">
|
||||||
|
|
||||||
|
<div class="card overflow-hidden p-0">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm text-left">
|
||||||
|
<thead class="text-xs text-accents-5 uppercase bg-accents-1/50 border-b border-accents-2 font-semibold tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-4 w-[250px]" data-i18n="common.name">Name</th>
|
||||||
|
<th class="px-6 py-4" data-i18n="common.description">Description</th>
|
||||||
|
<th class="px-6 py-4 w-[100px]" data-i18n="common.version">Version</th>
|
||||||
|
<th class="px-6 py-4 w-[150px]" data-i18n="common.author">Author</th>
|
||||||
|
<th class="px-6 py-4 w-[100px] text-right" data-i18n="common.status">Status</th>
|
||||||
|
<th class="px-6 py-4 w-[100px] text-right" data-i18n="common.actions">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-accents-2">
|
||||||
|
<?php if(empty($plugins)): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-6 py-12 text-center text-accents-5">
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<div class="p-3 rounded-full bg-accents-1">
|
||||||
|
<i data-lucide="package-search" class="w-6 h-6 text-accents-4"></i>
|
||||||
|
</div>
|
||||||
|
<span class="font-medium" data-i18n="settings.no_plugins">No plugins installed</span>
|
||||||
|
<span class="text-xs" data-i18n="settings.no_plugins_desc">Upload a .zip file to get started.</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach($plugins as $plugin): ?>
|
||||||
|
<tr class="group hover:bg-accents-1/30 transition-colors">
|
||||||
|
<td class="px-6 py-4 font-medium text-foreground">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="h-8 w-8 rounded bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
<i data-lucide="plug" class="w-4 h-4"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span><?= htmlspecialchars($plugin['name']) ?></span>
|
||||||
|
<span class="text-[10px] text-accents-4 font-normal font-mono"><?= htmlspecialchars($plugin['id']) ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-accents-6">
|
||||||
|
<?= htmlspecialchars($plugin['description']) ?>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-accents-6 font-mono text-xs">
|
||||||
|
<?= htmlspecialchars($plugin['version']) ?>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-accents-6">
|
||||||
|
<?= htmlspecialchars($plugin['author']) ?>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right">
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-500/10 text-green-600 dark:text-green-400">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right">
|
||||||
|
<form action="/settings/plugins/delete" method="POST" class="inline" onsubmit="event.preventDefault();
|
||||||
|
const title = window.i18n ? window.i18n.t('settings.delete_plugin') : 'Delete Plugin?';
|
||||||
|
const msg = window.i18n ? window.i18n.t('settings.delete_plugin_confirm', {name: '<?= htmlspecialchars($plugin['name']) ?>'}) : 'Delete this plugin?';
|
||||||
|
|
||||||
|
Mivo.confirm(title, msg, window.i18n ? window.i18n.t('common.delete') : 'Delete', window.i18n ? window.i18n.t('common.cancel') : 'Cancel').then(res => {
|
||||||
|
if(res) this.submit();
|
||||||
|
});">
|
||||||
|
<input type="hidden" name="plugin_id" value="<?= htmlspecialchars($plugin['id']) ?>">
|
||||||
|
<button type="submit" class="btn-icon-danger" title="Delete">
|
||||||
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function openUploadModal() {
|
||||||
|
const title = window.i18n ? window.i18n.t('settings.upload_plugin') : 'Upload Plugin';
|
||||||
|
const html = `
|
||||||
|
<form id="upload-plugin-form" action="/settings/plugins/upload" method="POST" enctype="multipart/form-data" class="space-y-4">
|
||||||
|
<div class="text-sm text-accents-5">
|
||||||
|
<p class="mb-4" data-i18n="settings.upload_plugin_desc">Select a plugin .zip file to install.</p>
|
||||||
|
<input type="file" name="plugin_file" accept=".zip" required class="form-control-file w-full">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
|
||||||
|
Mivo.modal.form(title, html, window.i18n ? window.i18n.t('common.install') : 'Install', () => {
|
||||||
|
const form = document.getElementById('upload-plugin-form');
|
||||||
|
if (form.reportValidity()) {
|
||||||
|
form.submit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||||
@@ -276,12 +276,12 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
|||||||
'{{timelimit}}': ' 3 Hours',
|
'{{timelimit}}': ' 3 Hours',
|
||||||
'{{datalimit}}': '500 MB',
|
'{{datalimit}}': '500 MB',
|
||||||
'{{profile}}': 'General',
|
'{{profile}}': 'General',
|
||||||
'{{comment}}': 'mikhmon',
|
'{{comment}}': 'mivo',
|
||||||
'{{hotspotname}}': 'Mikhmon Hotspot',
|
'{{hotspotname}}': 'Mivo Hotspot',
|
||||||
'{{num}}': '1',
|
'{{num}}': '1',
|
||||||
'{{logo}}': '<img src="/assets/img/logo.png" style="height:30px;border:0;">', // Default placeholder
|
'{{logo}}': '<img src="/assets/img/logo.png" style="height:30px;border:0;">', // Default placeholder
|
||||||
'{{dns_name}}': 'hotspot.mikhmon',
|
'{{dns_name}}': 'hotspot.mivo',
|
||||||
'{{login_url}}': 'http://hotspot.mikhmon/login',
|
'{{login_url}}': 'http://hotspot.mivo/login',
|
||||||
};
|
};
|
||||||
|
|
||||||
function updatePreview() {
|
function updatePreview() {
|
||||||
|
|||||||
53
deploy.ps1
53
deploy.ps1
@@ -1,53 +0,0 @@
|
|||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
$RemotePath = "/www/wwwroot/app.mivo.dyzulk.com"
|
|
||||||
|
|
||||||
Write-Host "Starting Deployment to app.mivo.dyzulk.com..." -ForegroundColor Green
|
|
||||||
|
|
||||||
# 1. Build Assets
|
|
||||||
Write-Host "Building assets..." -ForegroundColor Cyan
|
|
||||||
cmd /c "npm run build"
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Error "Build failed!"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 2. Create Archive
|
|
||||||
Write-Host "Creating deployment package..." -ForegroundColor Cyan
|
|
||||||
# Excluding potential garbage
|
|
||||||
$excludeParams = @("--exclude", "node_modules", "--exclude", ".git", "--exclude", ".github", "--exclude", "temp_debug", "--exclude", "deploy.ps1", "--exclude", "*.tar.gz")
|
|
||||||
tar -czf deploy_package.tar.gz @excludeParams app public routes mivo src package.json
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Error "Failed to create archive!"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 3. Upload
|
|
||||||
Write-Host "Uploading to server ($RemotePath)..." -ForegroundColor Cyan
|
|
||||||
scp deploy_package.tar.gz "aapanel:$RemotePath/"
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Error "SCP upload failed!"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 4. Extract and Cleanup on Server
|
|
||||||
Write-Host "Extracting and configuring permissions..." -ForegroundColor Cyan
|
|
||||||
# Commands:
|
|
||||||
# 1. cd to remote path
|
|
||||||
# 2. Extract
|
|
||||||
# 3. Set ownership to www:www
|
|
||||||
# 4. Set mivo executable
|
|
||||||
# 5. Set public folder to 755 (Laravel recommendation)
|
|
||||||
# 6. Cleanup archive
|
|
||||||
$remoteCommands = "cd $RemotePath && tar -xzf deploy_package.tar.gz && chown -R www:www . && chmod +x mivo && chmod -R 755 public && rm deploy_package.tar.gz"
|
|
||||||
|
|
||||||
ssh aapanel $remoteCommands
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Error "Remote deployment failed!"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 5. Local Cleanup
|
|
||||||
Write-Host "Cleaning up local package..." -ForegroundColor Cyan
|
|
||||||
if (Test-Path deploy_package.tar.gz) {
|
|
||||||
Remove-Item deploy_package.tar.gz
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Deployment successfully completed!" -ForegroundColor Green
|
|
||||||
2490
package-lock.json
generated
2490
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mivo",
|
"name": "mivo",
|
||||||
"version": "1.1.1",
|
"version": "1.2.0",
|
||||||
"description": "This is a complete rewrite of Mikhmon v3 using a modern MVC architecture.\r It runs on a lightweight custom core designed for performance on low-end devices (STB/Android).",
|
"description": "This is a complete rewrite of Mivo using a modern MVC architecture.\r It runs on a lightweight custom core designed for performance on low-end devices (STB/Android).",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"flag-icons": "^7.5.0",
|
"flag-icons": "^7.5.0",
|
||||||
"lucide": "^0.562.0",
|
"lucide": "^0.562.0",
|
||||||
"lucide-vue-next": "^0.562.0",
|
"qrious": "^4.0.2",
|
||||||
"qrious": "^4.0.2"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/runtime": "^7.28.6",
|
"@babel/runtime": "^7.28.6",
|
||||||
@@ -35,8 +35,6 @@
|
|||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"esbuild": "^0.27.2",
|
"esbuild": "^0.27.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17"
|
||||||
"vitepress": "^1.0.0-rc.31",
|
|
||||||
"vue": "^3.4.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -746,6 +746,12 @@ body {
|
|||||||
transition-duration: 200ms;
|
transition-duration: 200ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.btn-icon {
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.btn-icon:hover {
|
.btn-icon:hover {
|
||||||
background-color: rgb(255 255 255 / 0.4);
|
background-color: rgb(255 255 255 / 0.4);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
@@ -802,6 +808,12 @@ body {
|
|||||||
transition-duration: 200ms;
|
transition-duration: 200ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.form-label {
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.form-label:is(.dark *) {
|
.form-label:is(.dark *) {
|
||||||
color: var(--accents-3);
|
color: var(--accents-3);
|
||||||
}
|
}
|
||||||
@@ -1009,6 +1021,12 @@ input:-webkit-autofill,
|
|||||||
transition-duration: 200ms;
|
transition-duration: 200ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.input-group {
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.input-group:focus-within {
|
.input-group:focus-within {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
@@ -1044,6 +1062,12 @@ input:-webkit-autofill,
|
|||||||
color: var(--accents-5);
|
color: var(--accents-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.input-suffix {
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Merged Inputs (Side-by-side) */
|
/* Merged Inputs (Side-by-side) */
|
||||||
|
|
||||||
.input-group-merged > .form-control:not(:first-child),
|
.input-group-merged > .form-control:not(:first-child),
|
||||||
@@ -1078,6 +1102,12 @@ input:-webkit-autofill,
|
|||||||
transition-duration: 150ms;
|
transition-duration: 150ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.form-control-file {
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.form-control-file::file-selector-button {
|
.form-control-file::file-selector-button {
|
||||||
margin-right: 1rem;
|
margin-right: 1rem;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
@@ -1418,6 +1448,12 @@ input:-webkit-autofill,
|
|||||||
transition-duration: 300ms;
|
transition-duration: 300ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.segmented-switch-btn {
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Active icon colors based on theme */
|
/* Active icon colors based on theme */
|
||||||
|
|
||||||
/* In Light Mode: Track is light, Slider is Black. We want Active icon to be WHITE on the Slider. */
|
/* In Light Mode: Track is light, Slider is Black. We want Active icon to be WHITE on the Slider. */
|
||||||
@@ -1431,6 +1467,12 @@ input:-webkit-autofill,
|
|||||||
color: var(--accents-5);
|
color: var(--accents-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.dark .theme-toggle-light-icon {
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dark .theme-toggle-light-icon:hover {
|
.dark .theme-toggle-light-icon:hover {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
|
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
|
||||||
@@ -1442,6 +1484,12 @@ input:-webkit-autofill,
|
|||||||
color: var(--accents-5);
|
color: var(--accents-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.theme-toggle-dark-icon {
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.theme-toggle-dark-icon:hover {
|
.theme-toggle-dark-icon:hover {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(0 0 0 / var(--tw-text-opacity, 1));
|
color: rgb(0 0 0 / var(--tw-text-opacity, 1));
|
||||||
@@ -1558,6 +1606,12 @@ input:-webkit-autofill,
|
|||||||
color: var(--accents-5);
|
color: var(--accents-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.table-glass th {
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.table-glass tbody > :not([hidden]) ~ :not([hidden]) {
|
.table-glass tbody > :not([hidden]) ~ :not([hidden]) {
|
||||||
--tw-divide-y-reverse: 0;
|
--tw-divide-y-reverse: 0;
|
||||||
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
|
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
|
||||||
@@ -1652,6 +1706,10 @@ input:-webkit-autofill,
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collapse {
|
||||||
|
visibility: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
.static {
|
.static {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
@@ -1798,6 +1856,10 @@ input:-webkit-autofill,
|
|||||||
top: 100%;
|
top: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.isolate {
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
.z-0 {
|
.z-0 {
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@@ -1984,14 +2046,58 @@ input:-webkit-autofill,
|
|||||||
display: table;
|
display: table;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-table {
|
||||||
|
display: inline-table;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-caption {
|
||||||
|
display: table-caption;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-cell {
|
||||||
|
display: table-cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-column {
|
||||||
|
display: table-column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-column-group {
|
||||||
|
display: table-column-group;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-footer-group {
|
||||||
|
display: table-footer-group;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header-group {
|
||||||
|
display: table-header-group;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row-group {
|
||||||
|
display: table-row-group;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
display: table-row;
|
||||||
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-grid {
|
||||||
|
display: inline-grid;
|
||||||
|
}
|
||||||
|
|
||||||
.contents {
|
.contents {
|
||||||
display: contents;
|
display: contents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
display: list-item;
|
||||||
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -2088,10 +2194,6 @@ input:-webkit-autofill,
|
|||||||
height: 400px;
|
height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h-\[500px\] {
|
|
||||||
height: 500px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h-\[60vw\] {
|
.h-\[60vw\] {
|
||||||
height: 60vw;
|
height: 60vw;
|
||||||
}
|
}
|
||||||
@@ -2220,6 +2322,18 @@ input:-webkit-autofill,
|
|||||||
width: 2.25rem;
|
width: 2.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.w-\[100px\] {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-\[150px\] {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-\[250px\] {
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
.w-\[60vw\] {
|
.w-\[60vw\] {
|
||||||
width: 60vw;
|
width: 60vw;
|
||||||
}
|
}
|
||||||
@@ -2313,6 +2427,10 @@ input:-webkit-autofill,
|
|||||||
flex-shrink: 0 !important;
|
flex-shrink: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex-shrink {
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.flex-shrink-0 {
|
.flex-shrink-0 {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -2459,10 +2577,6 @@ input:-webkit-autofill,
|
|||||||
user-select: all;
|
user-select: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-none {
|
|
||||||
resize: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resize {
|
.resize {
|
||||||
resize: both;
|
resize: both;
|
||||||
}
|
}
|
||||||
@@ -2635,10 +2749,18 @@ input:-webkit-autofill,
|
|||||||
border-bottom-width: calc(1px * var(--tw-divide-y-reverse));
|
border-bottom-width: calc(1px * var(--tw-divide-y-reverse));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.divide-accents-2 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
border-color: var(--accents-2);
|
||||||
|
}
|
||||||
|
|
||||||
.divide-white\/10 > :not([hidden]) ~ :not([hidden]) {
|
.divide-white\/10 > :not([hidden]) ~ :not([hidden]) {
|
||||||
border-color: rgb(255 255 255 / 0.1);
|
border-color: rgb(255 255 255 / 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.self-start {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.self-end {
|
.self-end {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
}
|
}
|
||||||
@@ -2797,6 +2919,10 @@ input:-webkit-autofill,
|
|||||||
border-style: none;
|
border-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.\!border-green-500\/20 {
|
||||||
|
border-color: rgb(34 197 94 / 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.\!border-red-500\/30 {
|
.\!border-red-500\/30 {
|
||||||
border-color: rgb(239 68 68 / 0.3) !important;
|
border-color: rgb(239 68 68 / 0.3) !important;
|
||||||
}
|
}
|
||||||
@@ -2856,6 +2982,14 @@ input:-webkit-autofill,
|
|||||||
border-color: rgb(239 68 68 / var(--tw-border-opacity, 1));
|
border-color: rgb(239 68 68 / var(--tw-border-opacity, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border-red-500\/10 {
|
||||||
|
border-color: rgb(239 68 68 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-red-500\/20 {
|
||||||
|
border-color: rgb(239 68 68 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.border-slate-200 {
|
.border-slate-200 {
|
||||||
--tw-border-opacity: 1;
|
--tw-border-opacity: 1;
|
||||||
border-color: rgb(226 232 240 / var(--tw-border-opacity, 1));
|
border-color: rgb(226 232 240 / var(--tw-border-opacity, 1));
|
||||||
@@ -2877,6 +3011,10 @@ input:-webkit-autofill,
|
|||||||
border-color: rgb(255 255 255 / 0.05);
|
border-color: rgb(255 255 255 / 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.\!bg-green-500\/10 {
|
||||||
|
background-color: rgb(34 197 94 / 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.\!bg-red-50\/50 {
|
.\!bg-red-50\/50 {
|
||||||
background-color: rgb(254 242 242 / 0.5) !important;
|
background-color: rgb(254 242 242 / 0.5) !important;
|
||||||
}
|
}
|
||||||
@@ -3330,6 +3468,10 @@ input:-webkit-autofill,
|
|||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pt-3 {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.pt-4 {
|
.pt-4 {
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
@@ -3427,6 +3569,10 @@ input:-webkit-autofill,
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.font-normal {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.font-semibold {
|
.font-semibold {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -3435,6 +3581,14 @@ input:-webkit-autofill,
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lowercase {
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capitalize {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
.italic {
|
.italic {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
@@ -3579,6 +3733,10 @@ input:-webkit-autofill,
|
|||||||
color: rgb(22 163 74 / var(--tw-text-opacity, 1));
|
color: rgb(22 163 74 / var(--tw-text-opacity, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-green-600\/70 {
|
||||||
|
color: rgb(22 163 74 / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
.text-green-700 {
|
.text-green-700 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(21 128 61 / var(--tw-text-opacity, 1));
|
color: rgb(21 128 61 / var(--tw-text-opacity, 1));
|
||||||
@@ -3614,6 +3772,10 @@ input:-webkit-autofill,
|
|||||||
color: rgb(248 113 113 / var(--tw-text-opacity, 1));
|
color: rgb(248 113 113 / var(--tw-text-opacity, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-red-400\/70 {
|
||||||
|
color: rgb(248 113 113 / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
.text-red-500 {
|
.text-red-500 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(239 68 68 / var(--tw-text-opacity, 1));
|
color: rgb(239 68 68 / var(--tw-text-opacity, 1));
|
||||||
@@ -3663,6 +3825,14 @@ input:-webkit-autofill,
|
|||||||
text-decoration-line: underline;
|
text-decoration-line: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overline {
|
||||||
|
text-decoration-line: overline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-through {
|
||||||
|
text-decoration-line: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
.decoration-0 {
|
.decoration-0 {
|
||||||
text-decoration-thickness: 0px;
|
text-decoration-thickness: 0px;
|
||||||
}
|
}
|
||||||
@@ -3672,6 +3842,11 @@ input:-webkit-autofill,
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subpixel-antialiased {
|
||||||
|
-webkit-font-smoothing: auto;
|
||||||
|
-moz-osx-font-smoothing: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.opacity-0 {
|
.opacity-0 {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
@@ -3726,6 +3901,11 @@ input:-webkit-autofill,
|
|||||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.outline-none {
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.outline {
|
.outline {
|
||||||
outline-style: solid;
|
outline-style: solid;
|
||||||
}
|
}
|
||||||
@@ -3773,6 +3953,11 @@ input:-webkit-autofill,
|
|||||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invert {
|
||||||
|
--tw-invert: invert(100%);
|
||||||
|
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
.filter {
|
.filter {
|
||||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||||
}
|
}
|
||||||
@@ -4059,6 +4244,12 @@ div.swal2-confirm:hover {
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
div.swal2-confirm {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
div.swal2-confirm {
|
div.swal2-confirm {
|
||||||
border-radius: 0.5rem !important;
|
border-radius: 0.5rem !important;
|
||||||
}
|
}
|
||||||
@@ -4121,6 +4312,12 @@ div.swal2-cancel:hover {
|
|||||||
background-color: var(--accents-1);
|
background-color: var(--accents-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
div.swal2-cancel {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
div.swal2-cancel {
|
div.swal2-cancel {
|
||||||
border-radius: 0.5rem !important;
|
border-radius: 0.5rem !important;
|
||||||
}
|
}
|
||||||
@@ -4350,6 +4547,85 @@ div:where(.swal2-container) div:where(.swal2-popup).swal2-premium-card {
|
|||||||
border-color: var(--foreground);
|
border-color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Hide Everything by default */
|
||||||
|
|
||||||
|
.no-print,
|
||||||
|
.navbar,
|
||||||
|
.sidebar,
|
||||||
|
.btn,
|
||||||
|
.fixed,
|
||||||
|
nav,
|
||||||
|
header,
|
||||||
|
footer,
|
||||||
|
.datatable-wrapper > div:first-child, /* Hide Datatable Header (Search/Length) */
|
||||||
|
.datatable-wrapper > div:last-child, /* Hide Pagination */
|
||||||
|
#pagination-controls,
|
||||||
|
.custom-select-wrapper {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset Body */
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: white !important;
|
||||||
|
color: black !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset Main Content Area */
|
||||||
|
|
||||||
|
main, #main-content, .content-wrapper {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: none !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force Grid Columns for Cards */
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: repeat(2, 1fr) !important;
|
||||||
|
gap: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
|
||||||
|
.table-glass {
|
||||||
|
border: 1px solid #ccc !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-glass th,
|
||||||
|
.table-glass td {
|
||||||
|
color: black !important;
|
||||||
|
border: 1px solid #ddd !important;
|
||||||
|
white-space: normal !important;
|
||||||
|
/* Allow wrapping */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
|
||||||
|
.card, .glass-card {
|
||||||
|
border: 1px solid #ccc !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: none !important;
|
||||||
|
color: black !important;
|
||||||
|
-moz-column-break-inside: avoid;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text Colors */
|
||||||
|
|
||||||
|
.text-accents-5, .text-accents-6 {
|
||||||
|
color: #444 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.selection\:bg-accents-2 *::-moz-selection {
|
.selection\:bg-accents-2 *::-moz-selection {
|
||||||
background-color: var(--accents-2);
|
background-color: var(--accents-2);
|
||||||
}
|
}
|
||||||
@@ -4857,6 +5133,10 @@ div:where(.swal2-container) div:where(.swal2-popup).swal2-premium-card {
|
|||||||
color: rgb(74 222 128 / var(--tw-text-opacity, 1));
|
color: rgb(74 222 128 / var(--tw-text-opacity, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:text-green-400\/70:is(.dark *) {
|
||||||
|
color: rgb(74 222 128 / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:text-orange-400:is(.dark *) {
|
.dark\:text-orange-400:is(.dark *) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(251 146 60 / var(--tw-text-opacity, 1));
|
color: rgb(251 146 60 / var(--tw-text-opacity, 1));
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
/*
|
|
||||||
* Copyright (C) 2018 Laksamadi Guko.
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation; either version 2 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
echo "<meta http-equiv='refresh' content='0;url=../' />";
|
|
||||||
?>
|
|
||||||
|
|
||||||
@@ -45,6 +45,32 @@ class I18n {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend translations at runtime (e.g. from Plugins)
|
||||||
|
* @param {Object} newTranslations - Nested object matching the structure
|
||||||
|
*/
|
||||||
|
extend(newTranslations) {
|
||||||
|
// Deep merge helper
|
||||||
|
const deepMerge = (target, source) => {
|
||||||
|
for (const key in source) {
|
||||||
|
if (source[key] instanceof Object && key in target) {
|
||||||
|
Object.assign(source[key], deepMerge(target[key], source[key]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object.assign(target || {}, source);
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.translations = deepMerge(this.translations, newTranslations);
|
||||||
|
|
||||||
|
// Re-apply in case the new keys are already present in DOM
|
||||||
|
if (this.isLoaded) {
|
||||||
|
this.applyTranslations();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
applyTranslations() {
|
applyTranslations() {
|
||||||
document.querySelectorAll('[data-i18n]').forEach(element => {
|
document.querySelectorAll('[data-i18n]').forEach(element => {
|
||||||
const key = element.getAttribute('data-i18n');
|
const key = element.getAttribute('data-i18n');
|
||||||
|
|||||||
22
public/assets/vendor/xlsx/xlsx.full.min.js
vendored
Normal file
22
public/assets/vendor/xlsx/xlsx.full.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -10,6 +10,7 @@
|
|||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
|
"install": "Install",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@@ -146,12 +147,24 @@
|
|||||||
"tip_emmet_html": "<strong>HTML Boilerplate</strong>: Type <code>!</code> then <code>Tab</code>.",
|
"tip_emmet_html": "<strong>HTML Boilerplate</strong>: Type <code>!</code> then <code>Tab</code>.",
|
||||||
"tip_emmet_tag": "<strong>Auto-Tag</strong>: Type <code>.container</code> then <code>Tab</code> for <code><div class=\"container\"></code>.",
|
"tip_emmet_tag": "<strong>Auto-Tag</strong>: Type <code>.container</code> then <code>Tab</code> for <code><div class=\"container\"></code>.",
|
||||||
"tip_color_picker": "<strong>Color Picker</strong>: Click the color box next to hex codes (e.g., #ff0000) to open the picker.",
|
"tip_color_picker": "<strong>Color Picker</strong>: Click the color box next to hex codes (e.g., #ff0000) to open the picker.",
|
||||||
"tip_syntax_error": "<strong>Syntax Error</strong>: Red squiggles (and dots in the gutter) show structure errors like mismatched tags."
|
"tip_syntax_error": "<strong>Syntax Error</strong>: Red squiggles (and dots in the gutter) show structure errors like mismatched tags.",
|
||||||
|
"plugins_title": "Plugins",
|
||||||
|
"plugins": "Plugins",
|
||||||
|
"plugins_desc": "Manage and extend functionality with plugins.",
|
||||||
|
"upload_plugin": "Upload Plugin",
|
||||||
|
"upload_plugin_desc": "Select a plugin .zip file to install.",
|
||||||
|
"no_plugins": "No Plugins Installed",
|
||||||
|
"no_plugins_desc": "Upload a .zip file to get started.",
|
||||||
|
"plugin_installed": "Plugin Installed",
|
||||||
|
"plugin_installed_desc": "Plugin \"{name}\" has been installed successfully.",
|
||||||
|
"delete_plugin": "Delete Plugin?",
|
||||||
|
"delete_plugin_confirm": "Are you sure you want to delete plugin <strong>{name}</strong>? This cannot be undone.",
|
||||||
|
"install_failed": "Installation Failed"
|
||||||
},
|
},
|
||||||
"routers": {
|
"routers": {
|
||||||
"edit_router_title": "Edit Router",
|
"edit_router_title": "Edit Router",
|
||||||
"add_router_title": "Add Router",
|
"add_router_title": "Add Router",
|
||||||
"connect_desc": "Connect Mikhmon to your RouterOS device.",
|
"connect_desc": "Connect Mivo to your RouterOS device.",
|
||||||
"session_settings": "Session Settings",
|
"session_settings": "Session Settings",
|
||||||
"unique_id": "Unique ID. Preview:",
|
"unique_id": "Unique ID. Preview:",
|
||||||
"show_quick_access": "Show in Quick Access (Home Page)",
|
"show_quick_access": "Show in Quick Access (Home Page)",
|
||||||
@@ -575,7 +588,13 @@
|
|||||||
"cors_rule_updated": "CORS Rule Updated",
|
"cors_rule_updated": "CORS Rule Updated",
|
||||||
"cors_rule_updated_desc": "Changes to CORS rule for {origin} have been saved.",
|
"cors_rule_updated_desc": "Changes to CORS rule for {origin} have been saved.",
|
||||||
"cors_rule_deleted": "CORS Rule Deleted",
|
"cors_rule_deleted": "CORS Rule Deleted",
|
||||||
"cors_rule_deleted_desc": "The CORS rule has been removed."
|
"cors_rule_deleted_desc": "The CORS rule has been removed.",
|
||||||
|
"plugin_deleted": "Plugin Deleted",
|
||||||
|
"plugin_deleted_desc": "The plugin has been removed successfully.",
|
||||||
|
"upload_failed": "Upload Failed",
|
||||||
|
"install_failed": "Installation Failed",
|
||||||
|
"invalid_plugin_desc": "Invalid plugin structure: plugin.php not found.",
|
||||||
|
"zip_open_failed_desc": "Failed to open the uploaded zip file."
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"check_title": "Check Voucher Status",
|
"check_title": "Check Voucher Status",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"delete": "Hapus",
|
"delete": "Hapus",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"add": "Tambah",
|
"add": "Tambah",
|
||||||
|
"install": "Instal",
|
||||||
"back": "Kembali",
|
"back": "Kembali",
|
||||||
"actions": "Aksi",
|
"actions": "Aksi",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@@ -146,12 +147,24 @@
|
|||||||
"tip_emmet_html": "<strong>HTML Boilerplate</strong>: Ketik <code>!</code> lalu <code>Tab</code>.",
|
"tip_emmet_html": "<strong>HTML Boilerplate</strong>: Ketik <code>!</code> lalu <code>Tab</code>.",
|
||||||
"tip_emmet_tag": "<strong>Auto-Tag</strong>: Ketik <code>.container</code> lalu <code>Tab</code> untuk <code><div class=\"container\"></code>.",
|
"tip_emmet_tag": "<strong>Auto-Tag</strong>: Ketik <code>.container</code> lalu <code>Tab</code> untuk <code><div class=\"container\"></code>.",
|
||||||
"tip_color_picker": "<strong>Color Picker</strong>: Klik kotak warna di sebelah kode hex (misal: #ff0000) untuk membuka pemilih warna.",
|
"tip_color_picker": "<strong>Color Picker</strong>: Klik kotak warna di sebelah kode hex (misal: #ff0000) untuk membuka pemilih warna.",
|
||||||
"tip_syntax_error": "<strong>Syntax Error</strong>: Garis bawah merah (dan titik di samping angka baris) menunjukkan kesalahan struktur seperti tag yang tidak tertutup."
|
"tip_syntax_error": "<strong>Syntax Error</strong>: Garis bawah merah (dan titik di samping angka baris) menunjukkan kesalahan struktur seperti tag yang tidak tertutup.",
|
||||||
|
"plugins_title": "Plugin",
|
||||||
|
"plugins": "Plugin",
|
||||||
|
"plugins_desc": "Kelola dan perluas fungsionalitas dengan plugin.",
|
||||||
|
"upload_plugin": "Unggah Plugin",
|
||||||
|
"upload_plugin_desc": "Pilih file .zip plugin untuk diinstal.",
|
||||||
|
"no_plugins": "Belum Ada Plugin Terinstal",
|
||||||
|
"no_plugins_desc": "Unggah file .zip untuk memulai.",
|
||||||
|
"plugin_installed": "Plugin Terinstal",
|
||||||
|
"plugin_installed_desc": "Plugin \"{name}\" berhasil diinstal.",
|
||||||
|
"delete_plugin": "Hapus Plugin?",
|
||||||
|
"delete_plugin_confirm": "Apakah Anda yakin ingin menghapus plugin <strong>{name}</strong>? Tindakan ini tidak dapat dibatalkan.",
|
||||||
|
"install_failed": "Instalasi Gagal"
|
||||||
},
|
},
|
||||||
"routers": {
|
"routers": {
|
||||||
"edit_router_title": "Edit Router",
|
"edit_router_title": "Edit Router",
|
||||||
"add_router_title": "Tambah Router",
|
"add_router_title": "Tambah Router",
|
||||||
"connect_desc": "Hubungkan Mikhmon ke perangkat RouterOS Anda.",
|
"connect_desc": "Hubungkan Mivo ke perangkat RouterOS Anda.",
|
||||||
"session_settings": "Pengaturan Sesi",
|
"session_settings": "Pengaturan Sesi",
|
||||||
"unique_id": "ID Unik. Pratinjau:",
|
"unique_id": "ID Unik. Pratinjau:",
|
||||||
"show_quick_access": "Tampilkan di Akses Cepat (Beranda)",
|
"show_quick_access": "Tampilkan di Akses Cepat (Beranda)",
|
||||||
@@ -585,7 +598,13 @@
|
|||||||
"cors_rule_updated": "Aturan CORS Diperbarui",
|
"cors_rule_updated": "Aturan CORS Diperbarui",
|
||||||
"cors_rule_updated_desc": "Perubahan pada aturan CORS untuk {origin} berhasil disimpan.",
|
"cors_rule_updated_desc": "Perubahan pada aturan CORS untuk {origin} berhasil disimpan.",
|
||||||
"cors_rule_deleted": "Aturan CORS Dihapus",
|
"cors_rule_deleted": "Aturan CORS Dihapus",
|
||||||
"cors_rule_deleted_desc": "Aturan CORS berhasil dihapus."
|
"cors_rule_deleted_desc": "Aturan CORS berhasil dihapus.",
|
||||||
|
"plugin_deleted": "Plugin Dihapus",
|
||||||
|
"plugin_deleted_desc": "Plugin berhasil dihapus.",
|
||||||
|
"upload_failed": "Gagal Mengunggah",
|
||||||
|
"install_failed": "Instalasi Gagal",
|
||||||
|
"invalid_plugin_desc": "Struktur plugin tidak valid: plugin.php tidak ditemukan.",
|
||||||
|
"zip_open_failed_desc": "Gagal membuka file zip yang diunggah."
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"check_title": "Cek Status Voucher",
|
"check_title": "Cek Status Voucher",
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ $router->group(['middleware' => 'auth'], function($router) {
|
|||||||
$router->post('/settings/api-cors/update', [SettingsController::class, 'updateApiCors']);
|
$router->post('/settings/api-cors/update', [SettingsController::class, 'updateApiCors']);
|
||||||
$router->post('/settings/api-cors/delete', [SettingsController::class, 'deleteApiCors']);
|
$router->post('/settings/api-cors/delete', [SettingsController::class, 'deleteApiCors']);
|
||||||
|
|
||||||
|
// Plugins Management
|
||||||
|
$router->get('/settings/plugins', [SettingsController::class, 'plugins']);
|
||||||
|
$router->post('/settings/plugins/upload', [SettingsController::class, 'uploadPlugin']);
|
||||||
|
$router->post('/settings/plugins/delete', [SettingsController::class, 'deletePlugin']);
|
||||||
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Router Context Routes (Requires Auth AND Valid Router Session)
|
// Router Context Routes (Requires Auth AND Valid Router Session)
|
||||||
@@ -134,6 +139,7 @@ $router->group(['middleware' => 'auth'], function($router) {
|
|||||||
|
|
||||||
// Reports
|
// Reports
|
||||||
$router->get('/{session}/reports/selling', [ReportController::class, 'index']);
|
$router->get('/{session}/reports/selling', [ReportController::class, 'index']);
|
||||||
|
$router->get('/{session}/reports/selling/export/{type}', [ReportController::class, 'sellingExport']);
|
||||||
$router->get('/{session}/reports/resume', [ReportController::class, 'resume']);
|
$router->get('/{session}/reports/resume', [ReportController::class, 'resume']);
|
||||||
$router->get('/{session}/reports/user-log', [LogController::class, 'index']);
|
$router->get('/{session}/reports/user-log', [LogController::class, 'index']);
|
||||||
|
|
||||||
|
|||||||
@@ -48,4 +48,18 @@ if (fs.existsSync(flagIconsSrc)) {
|
|||||||
console.error('✗ flag-icons not found in node_modules.');
|
console.error('✗ flag-icons not found in node_modules.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Localize SheetJS (xlsx)
|
||||||
|
console.log('Localizing xlsx...');
|
||||||
|
const xlsxSrc = path.join(projectRoot, 'node_modules', 'xlsx', 'dist', 'xlsx.full.min.js');
|
||||||
|
const xlsxDestDir = path.join(publicVendor, 'xlsx');
|
||||||
|
|
||||||
|
if (fs.existsSync(xlsxSrc)) {
|
||||||
|
if (!fs.existsSync(xlsxDestDir)) fs.mkdirSync(xlsxDestDir, { recursive: true });
|
||||||
|
|
||||||
|
fs.copyFileSync(xlsxSrc, path.join(xlsxDestDir, 'xlsx.full.min.js'));
|
||||||
|
console.log('✓ xlsx localized.');
|
||||||
|
} else {
|
||||||
|
console.error('✗ xlsx not found in node_modules.');
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Asset synchronization complete.');
|
console.log('Asset synchronization complete.');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@echo off
|
@echo off
|
||||||
echo Starting Mikhmon v3 Remake Development Server...
|
echo Starting Mivo Remake Development Server...
|
||||||
echo.
|
echo.
|
||||||
echo Local: http://localhost:8000
|
echo Local: http://localhost:8000
|
||||||
echo Network URLs (Check below):
|
echo Network URLs (Check below):
|
||||||
|
|||||||
@@ -625,3 +625,72 @@ div:where(.swal2-container) div:where(.swal2-popup).swal2-premium-card {
|
|||||||
background-color: rgb(0 0 0 / 0.4);
|
background-color: rgb(0 0 0 / 0.4);
|
||||||
border-color: var(--foreground);
|
border-color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Hide Everything by default */
|
||||||
|
.no-print,
|
||||||
|
.navbar,
|
||||||
|
.sidebar,
|
||||||
|
.btn,
|
||||||
|
.fixed,
|
||||||
|
nav,
|
||||||
|
header,
|
||||||
|
footer,
|
||||||
|
.datatable-wrapper > div:first-child, /* Hide Datatable Header (Search/Length) */
|
||||||
|
.datatable-wrapper > div:last-child, /* Hide Pagination */
|
||||||
|
#pagination-controls,
|
||||||
|
.custom-select-wrapper {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset Body */
|
||||||
|
body {
|
||||||
|
background: white !important;
|
||||||
|
color: black !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset Main Content Area */
|
||||||
|
main, #main-content, .content-wrapper {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: none !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force Grid Columns for Cards */
|
||||||
|
.grid {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: repeat(2, 1fr) !important;
|
||||||
|
gap: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table-glass {
|
||||||
|
border: 1px solid #ccc !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
.table-glass th,
|
||||||
|
.table-glass td {
|
||||||
|
color: black !important;
|
||||||
|
border: 1px solid #ddd !important;
|
||||||
|
white-space: normal !important; /* Allow wrapping */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card, .glass-card {
|
||||||
|
border: 1px solid #ccc !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: none !important;
|
||||||
|
color: black !important;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text Colors */
|
||||||
|
.text-accents-5, .text-accents-6 {
|
||||||
|
color: #444 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user