feat:통계 대시보드 페이지 신규 구현 (/stats/dashboard)
- 모델 7개: StatSalesDaily, StatFinanceDaily, StatProductionDaily, StatInventoryDaily, StatSystemDaily, StatSalesMonthly, StatFinanceMonthly - DashboardStatService: 요약카드, 7일 추이차트, 알림, 월간요약 데이터 - StatDashboardController: HX-Redirect 패턴 적용 - 뷰: 요약카드 6개 + Chart.js 4개 차트 + 알림/월간요약 하단섹션 - 기존 대시보드 "통계 및 리포트" 바로가기 링크 연결 - 헤더 테넌트 선택 기준 전체/개별 테넌트 필터링 지원 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
47
app/Http/Controllers/Stats/StatDashboardController.php
Normal file
47
app/Http/Controllers/Stats/StatDashboardController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Stats;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\DashboardStatService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class StatDashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DashboardStatService $statService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 통계 대시보드
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
// Chart.js 스크립트가 @push('scripts')에 있으므로 HX-Redirect 필요
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('stats.dashboard'));
|
||||
}
|
||||
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$summaryCards = $this->statService->getSummaryCards($tenantId);
|
||||
$salesTrend = $this->statService->getSalesTrend($tenantId);
|
||||
$financeTrend = $this->statService->getFinanceTrend($tenantId);
|
||||
$productionTrend = $this->statService->getProductionTrend($tenantId);
|
||||
$systemTrend = $this->statService->getSystemTrend($tenantId);
|
||||
$recentAlerts = $this->statService->getRecentAlerts($tenantId);
|
||||
$monthlySummary = $this->statService->getMonthlySummary($tenantId);
|
||||
|
||||
return view('stats.dashboard.index', compact(
|
||||
'summaryCards',
|
||||
'salesTrend',
|
||||
'financeTrend',
|
||||
'productionTrend',
|
||||
'systemTrend',
|
||||
'recentAlerts',
|
||||
'monthlySummary',
|
||||
));
|
||||
}
|
||||
}
|
||||
43
app/Models/Stats/StatFinanceDaily.php
Normal file
43
app/Models/Stats/StatFinanceDaily.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Stats;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StatFinanceDaily extends Model
|
||||
{
|
||||
protected $connection = 'sam_stat';
|
||||
|
||||
protected $table = 'stat_finance_daily';
|
||||
|
||||
protected $casts = [
|
||||
'stat_date' => 'date',
|
||||
'deposit_count' => 'integer',
|
||||
'deposit_amount' => 'decimal:2',
|
||||
'withdrawal_count' => 'integer',
|
||||
'withdrawal_amount' => 'decimal:2',
|
||||
'net_cashflow' => 'decimal:2',
|
||||
'bank_balance_total' => 'decimal:2',
|
||||
'receivable_balance' => 'decimal:2',
|
||||
'payable_balance' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function scopeForTenant($query, ?int $tenantId)
|
||||
{
|
||||
if ($tenantId) {
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function scopeForDateRange($query, string $from, string $to)
|
||||
{
|
||||
return $query->whereBetween('stat_date', [$from, $to]);
|
||||
}
|
||||
|
||||
public function scopeForDate($query, string $date)
|
||||
{
|
||||
return $query->where('stat_date', $date);
|
||||
}
|
||||
}
|
||||
37
app/Models/Stats/StatFinanceMonthly.php
Normal file
37
app/Models/Stats/StatFinanceMonthly.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Stats;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StatFinanceMonthly extends Model
|
||||
{
|
||||
protected $connection = 'sam_stat';
|
||||
|
||||
protected $table = 'stat_finance_monthly';
|
||||
|
||||
protected $casts = [
|
||||
'stat_year' => 'integer',
|
||||
'stat_month' => 'integer',
|
||||
'deposit_total' => 'decimal:2',
|
||||
'withdrawal_total' => 'decimal:2',
|
||||
'net_cashflow' => 'decimal:2',
|
||||
'bank_balance_end' => 'decimal:2',
|
||||
'receivable_end' => 'decimal:2',
|
||||
'payable_end' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function scopeForTenant($query, ?int $tenantId)
|
||||
{
|
||||
if ($tenantId) {
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function scopeForMonth($query, int $year, int $month)
|
||||
{
|
||||
return $query->where('stat_year', $year)->where('stat_month', $month);
|
||||
}
|
||||
}
|
||||
41
app/Models/Stats/StatInventoryDaily.php
Normal file
41
app/Models/Stats/StatInventoryDaily.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Stats;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StatInventoryDaily extends Model
|
||||
{
|
||||
protected $connection = 'sam_stat';
|
||||
|
||||
protected $table = 'stat_inventory_daily';
|
||||
|
||||
protected $casts = [
|
||||
'stat_date' => 'date',
|
||||
'total_sku_count' => 'integer',
|
||||
'below_safety_count' => 'integer',
|
||||
'zero_stock_count' => 'integer',
|
||||
'excess_stock_count' => 'integer',
|
||||
'total_stock_value' => 'decimal:2',
|
||||
'turnover_rate' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function scopeForTenant($query, ?int $tenantId)
|
||||
{
|
||||
if ($tenantId) {
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function scopeForDateRange($query, string $from, string $to)
|
||||
{
|
||||
return $query->whereBetween('stat_date', [$from, $to]);
|
||||
}
|
||||
|
||||
public function scopeForDate($query, string $date)
|
||||
{
|
||||
return $query->where('stat_date', $date);
|
||||
}
|
||||
}
|
||||
41
app/Models/Stats/StatProductionDaily.php
Normal file
41
app/Models/Stats/StatProductionDaily.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Stats;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StatProductionDaily extends Model
|
||||
{
|
||||
protected $connection = 'sam_stat';
|
||||
|
||||
protected $table = 'stat_production_daily';
|
||||
|
||||
protected $casts = [
|
||||
'stat_date' => 'date',
|
||||
'production_qty' => 'decimal:2',
|
||||
'defect_qty' => 'decimal:2',
|
||||
'defect_rate' => 'decimal:2',
|
||||
'efficiency_rate' => 'decimal:2',
|
||||
'wo_created_count' => 'integer',
|
||||
'wo_completed_count' => 'integer',
|
||||
];
|
||||
|
||||
public function scopeForTenant($query, ?int $tenantId)
|
||||
{
|
||||
if ($tenantId) {
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function scopeForDateRange($query, string $from, string $to)
|
||||
{
|
||||
return $query->whereBetween('stat_date', [$from, $to]);
|
||||
}
|
||||
|
||||
public function scopeForDate($query, string $date)
|
||||
{
|
||||
return $query->where('stat_date', $date);
|
||||
}
|
||||
}
|
||||
41
app/Models/Stats/StatSalesDaily.php
Normal file
41
app/Models/Stats/StatSalesDaily.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Stats;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StatSalesDaily extends Model
|
||||
{
|
||||
protected $connection = 'sam_stat';
|
||||
|
||||
protected $table = 'stat_sales_daily';
|
||||
|
||||
protected $casts = [
|
||||
'stat_date' => 'date',
|
||||
'order_count' => 'integer',
|
||||
'order_amount' => 'decimal:2',
|
||||
'sales_count' => 'integer',
|
||||
'sales_amount' => 'decimal:2',
|
||||
'shipment_count' => 'integer',
|
||||
'shipment_amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function scopeForTenant($query, ?int $tenantId)
|
||||
{
|
||||
if ($tenantId) {
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function scopeForDateRange($query, string $from, string $to)
|
||||
{
|
||||
return $query->whereBetween('stat_date', [$from, $to]);
|
||||
}
|
||||
|
||||
public function scopeForDate($query, string $date)
|
||||
{
|
||||
return $query->where('stat_date', $date);
|
||||
}
|
||||
}
|
||||
36
app/Models/Stats/StatSalesMonthly.php
Normal file
36
app/Models/Stats/StatSalesMonthly.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Stats;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StatSalesMonthly extends Model
|
||||
{
|
||||
protected $connection = 'sam_stat';
|
||||
|
||||
protected $table = 'stat_sales_monthly';
|
||||
|
||||
protected $casts = [
|
||||
'stat_year' => 'integer',
|
||||
'stat_month' => 'integer',
|
||||
'order_count' => 'integer',
|
||||
'order_amount' => 'decimal:2',
|
||||
'sales_count' => 'integer',
|
||||
'sales_amount' => 'decimal:2',
|
||||
'avg_order_amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function scopeForTenant($query, ?int $tenantId)
|
||||
{
|
||||
if ($tenantId) {
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function scopeForMonth($query, int $year, int $month)
|
||||
{
|
||||
return $query->where('stat_year', $year)->where('stat_month', $month);
|
||||
}
|
||||
}
|
||||
40
app/Models/Stats/StatSystemDaily.php
Normal file
40
app/Models/Stats/StatSystemDaily.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Stats;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StatSystemDaily extends Model
|
||||
{
|
||||
protected $connection = 'sam_stat';
|
||||
|
||||
protected $table = 'stat_system_daily';
|
||||
|
||||
protected $casts = [
|
||||
'stat_date' => 'date',
|
||||
'active_user_count' => 'integer',
|
||||
'login_count' => 'integer',
|
||||
'api_request_count' => 'integer',
|
||||
'api_error_count' => 'integer',
|
||||
'api_avg_response_ms' => 'integer',
|
||||
];
|
||||
|
||||
public function scopeForTenant($query, ?int $tenantId)
|
||||
{
|
||||
if ($tenantId) {
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function scopeForDateRange($query, string $from, string $to)
|
||||
{
|
||||
return $query->whereBetween('stat_date', [$from, $to]);
|
||||
}
|
||||
|
||||
public function scopeForDate($query, string $date)
|
||||
{
|
||||
return $query->where('stat_date', $date);
|
||||
}
|
||||
}
|
||||
297
app/Services/DashboardStatService.php
Normal file
297
app/Services/DashboardStatService.php
Normal file
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Stats\StatAlert;
|
||||
use App\Models\Stats\StatFinanceDaily;
|
||||
use App\Models\Stats\StatFinanceMonthly;
|
||||
use App\Models\Stats\StatInventoryDaily;
|
||||
use App\Models\Stats\StatProductionDaily;
|
||||
use App\Models\Stats\StatSalesDaily;
|
||||
use App\Models\Stats\StatSalesMonthly;
|
||||
use App\Models\Stats\StatSystemDaily;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class DashboardStatService
|
||||
{
|
||||
/**
|
||||
* 요약 카드 데이터 (오늘 + 어제)
|
||||
*/
|
||||
public function getSummaryCards(?int $tenantId): array
|
||||
{
|
||||
$today = Carbon::today()->toDateString();
|
||||
$yesterday = Carbon::yesterday()->toDateString();
|
||||
|
||||
$salesToday = $this->getDailySales($tenantId, $today);
|
||||
$salesYesterday = $this->getDailySales($tenantId, $yesterday);
|
||||
|
||||
$financeToday = $this->getDailyFinance($tenantId, $today);
|
||||
$financeYesterday = $this->getDailyFinance($tenantId, $yesterday);
|
||||
|
||||
$productionToday = $this->getDailyProduction($tenantId, $today);
|
||||
$productionYesterday = $this->getDailyProduction($tenantId, $yesterday);
|
||||
|
||||
$inventoryToday = $this->getDailyInventory($tenantId, $today);
|
||||
$inventoryYesterday = $this->getDailyInventory($tenantId, $yesterday);
|
||||
|
||||
$systemToday = $this->getDailySystem($tenantId, $today);
|
||||
$systemYesterday = $this->getDailySystem($tenantId, $yesterday);
|
||||
|
||||
return [
|
||||
'orders' => [
|
||||
'count' => $salesToday['order_count'],
|
||||
'amount' => $salesToday['order_amount'],
|
||||
'delta_count' => $salesToday['order_count'] - $salesYesterday['order_count'],
|
||||
'delta_amount' => $salesToday['order_amount'] - $salesYesterday['order_amount'],
|
||||
],
|
||||
'sales' => [
|
||||
'amount' => $salesToday['sales_amount'],
|
||||
'delta' => $salesToday['sales_amount'] - $salesYesterday['sales_amount'],
|
||||
],
|
||||
'cashflow' => [
|
||||
'net' => $financeToday['net_cashflow'],
|
||||
'delta' => $financeToday['net_cashflow'] - $financeYesterday['net_cashflow'],
|
||||
],
|
||||
'production' => [
|
||||
'efficiency' => $productionToday['efficiency_rate'],
|
||||
'defect_rate' => $productionToday['defect_rate'],
|
||||
'delta_efficiency' => $productionToday['efficiency_rate'] - $productionYesterday['efficiency_rate'],
|
||||
],
|
||||
'inventory' => [
|
||||
'below_safety' => $inventoryToday['below_safety_count'],
|
||||
'delta' => $inventoryToday['below_safety_count'] - $inventoryYesterday['below_safety_count'],
|
||||
],
|
||||
'system' => [
|
||||
'active_users' => $systemToday['active_user_count'],
|
||||
'delta' => $systemToday['active_user_count'] - $systemYesterday['active_user_count'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 매출 추이 (7일)
|
||||
*/
|
||||
public function getSalesTrend(?int $tenantId, int $days = 7): array
|
||||
{
|
||||
$from = Carbon::today()->subDays($days - 1)->toDateString();
|
||||
$to = Carbon::today()->toDateString();
|
||||
|
||||
$rows = StatSalesDaily::forTenant($tenantId)
|
||||
->forDateRange($from, $to)
|
||||
->selectRaw('stat_date, SUM(order_amount) as order_amount, SUM(sales_amount) as sales_amount')
|
||||
->groupBy('stat_date')
|
||||
->orderBy('stat_date')
|
||||
->get();
|
||||
|
||||
return $this->fillDates($this->generateDateRange($from, $to), $rows, ['order_amount', 'sales_amount']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 자금 흐름 추이 (7일)
|
||||
*/
|
||||
public function getFinanceTrend(?int $tenantId, int $days = 7): array
|
||||
{
|
||||
$from = Carbon::today()->subDays($days - 1)->toDateString();
|
||||
$to = Carbon::today()->toDateString();
|
||||
|
||||
$rows = StatFinanceDaily::forTenant($tenantId)
|
||||
->forDateRange($from, $to)
|
||||
->selectRaw('stat_date, SUM(deposit_amount) as deposit_amount, SUM(withdrawal_amount) as withdrawal_amount')
|
||||
->groupBy('stat_date')
|
||||
->orderBy('stat_date')
|
||||
->get();
|
||||
|
||||
return $this->fillDates($this->generateDateRange($from, $to), $rows, ['deposit_amount', 'withdrawal_amount']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 생산 현황 추이 (7일)
|
||||
*/
|
||||
public function getProductionTrend(?int $tenantId, int $days = 7): array
|
||||
{
|
||||
$from = Carbon::today()->subDays($days - 1)->toDateString();
|
||||
$to = Carbon::today()->toDateString();
|
||||
|
||||
$rows = StatProductionDaily::forTenant($tenantId)
|
||||
->forDateRange($from, $to)
|
||||
->selectRaw('stat_date, SUM(production_qty) as production_qty, AVG(defect_rate) as defect_rate')
|
||||
->groupBy('stat_date')
|
||||
->orderBy('stat_date')
|
||||
->get();
|
||||
|
||||
return $this->fillDates($this->generateDateRange($from, $to), $rows, ['production_qty', 'defect_rate']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 활동 추이 (7일)
|
||||
*/
|
||||
public function getSystemTrend(?int $tenantId, int $days = 7): array
|
||||
{
|
||||
$from = Carbon::today()->subDays($days - 1)->toDateString();
|
||||
$to = Carbon::today()->toDateString();
|
||||
|
||||
$rows = StatSystemDaily::forTenant($tenantId)
|
||||
->forDateRange($from, $to)
|
||||
->selectRaw('stat_date, SUM(active_user_count) as active_user_count, SUM(api_request_count) as api_request_count')
|
||||
->groupBy('stat_date')
|
||||
->orderBy('stat_date')
|
||||
->get();
|
||||
|
||||
return $this->fillDates($this->generateDateRange($from, $to), $rows, ['active_user_count', 'api_request_count']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 알림 5건
|
||||
*/
|
||||
public function getRecentAlerts(?int $tenantId, int $limit = 5): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
$query = StatAlert::query()
|
||||
->where('is_resolved', false)
|
||||
->orderByDesc('created_at')
|
||||
->limit($limit);
|
||||
|
||||
if ($tenantId) {
|
||||
$query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 이번 달 도메인별 요약
|
||||
*/
|
||||
public function getMonthlySummary(?int $tenantId): array
|
||||
{
|
||||
$year = (int) Carbon::now()->format('Y');
|
||||
$month = (int) Carbon::now()->format('n');
|
||||
$monthLabel = Carbon::now()->format('Y-m');
|
||||
|
||||
$sales = StatSalesMonthly::forTenant($tenantId)
|
||||
->forMonth($year, $month)
|
||||
->selectRaw('SUM(order_count) as order_count, SUM(order_amount) as order_amount, SUM(sales_amount) as sales_amount')
|
||||
->first();
|
||||
|
||||
$finance = StatFinanceMonthly::forTenant($tenantId)
|
||||
->forMonth($year, $month)
|
||||
->selectRaw('SUM(deposit_total) as deposit_total, SUM(withdrawal_total) as withdrawal_total, SUM(net_cashflow) as net_cashflow')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'month' => $monthLabel,
|
||||
'sales' => [
|
||||
'order_count' => (int) ($sales->order_count ?? 0),
|
||||
'order_amount' => (float) ($sales->order_amount ?? 0),
|
||||
'sales_amount' => (float) ($sales->sales_amount ?? 0),
|
||||
],
|
||||
'finance' => [
|
||||
'deposit_total' => (float) ($finance->deposit_total ?? 0),
|
||||
'withdrawal_total' => (float) ($finance->withdrawal_total ?? 0),
|
||||
'net_cashflow' => (float) ($finance->net_cashflow ?? 0),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// --- Private Helpers ---
|
||||
|
||||
private function getDailySales(?int $tenantId, string $date): array
|
||||
{
|
||||
$row = StatSalesDaily::forTenant($tenantId)
|
||||
->forDate($date)
|
||||
->selectRaw('SUM(order_count) as order_count, SUM(order_amount) as order_amount, SUM(sales_amount) as sales_amount')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'order_count' => (int) ($row->order_count ?? 0),
|
||||
'order_amount' => (float) ($row->order_amount ?? 0),
|
||||
'sales_amount' => (float) ($row->sales_amount ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function getDailyFinance(?int $tenantId, string $date): array
|
||||
{
|
||||
$row = StatFinanceDaily::forTenant($tenantId)
|
||||
->forDate($date)
|
||||
->selectRaw('SUM(net_cashflow) as net_cashflow, SUM(deposit_amount) as deposit_amount, SUM(withdrawal_amount) as withdrawal_amount')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'net_cashflow' => (float) ($row->net_cashflow ?? 0),
|
||||
'deposit_amount' => (float) ($row->deposit_amount ?? 0),
|
||||
'withdrawal_amount' => (float) ($row->withdrawal_amount ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function getDailyProduction(?int $tenantId, string $date): array
|
||||
{
|
||||
$row = StatProductionDaily::forTenant($tenantId)
|
||||
->forDate($date)
|
||||
->selectRaw('AVG(efficiency_rate) as efficiency_rate, AVG(defect_rate) as defect_rate')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'efficiency_rate' => (float) ($row->efficiency_rate ?? 0),
|
||||
'defect_rate' => (float) ($row->defect_rate ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function getDailyInventory(?int $tenantId, string $date): array
|
||||
{
|
||||
$row = StatInventoryDaily::forTenant($tenantId)
|
||||
->forDate($date)
|
||||
->selectRaw('SUM(below_safety_count) as below_safety_count')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'below_safety_count' => (int) ($row->below_safety_count ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function getDailySystem(?int $tenantId, string $date): array
|
||||
{
|
||||
$row = StatSystemDaily::forTenant($tenantId)
|
||||
->forDate($date)
|
||||
->selectRaw('SUM(active_user_count) as active_user_count')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'active_user_count' => (int) ($row->active_user_count ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function generateDateRange(string $from, string $to): array
|
||||
{
|
||||
$dates = [];
|
||||
$current = Carbon::parse($from);
|
||||
$end = Carbon::parse($to);
|
||||
|
||||
while ($current->lte($end)) {
|
||||
$dates[] = $current->toDateString();
|
||||
$current->addDay();
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
|
||||
private function fillDates(array $dates, $rows, array $fields): array
|
||||
{
|
||||
$indexed = $rows->keyBy(fn ($row) => Carbon::parse($row->stat_date)->toDateString());
|
||||
|
||||
$result = [
|
||||
'labels' => [],
|
||||
'datasets' => [],
|
||||
];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$result['datasets'][$field] = [];
|
||||
}
|
||||
|
||||
foreach ($dates as $date) {
|
||||
$result['labels'][] = Carbon::parse($date)->format('m/d');
|
||||
foreach ($fields as $field) {
|
||||
$result['datasets'][$field][] = (float) ($indexed[$date]?->$field ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@
|
||||
<div class="bg-white rounded-lg shadow p-6 hover:shadow-lg transition-shadow">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">통계 및 리포트</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">시스템 통계를 확인합니다.</p>
|
||||
<a href="#" class="inline-flex items-center text-primary hover:underline">
|
||||
<a href="{{ route('stats.dashboard') }}" class="inline-flex items-center text-primary hover:underline">
|
||||
바로가기
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
|
||||
242
resources/views/stats/dashboard/index.blade.php
Normal file
242
resources/views/stats/dashboard/index.blade.php
Normal file
@@ -0,0 +1,242 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '통계 대시보드')
|
||||
@section('page-title', '통계 대시보드')
|
||||
|
||||
@section('content')
|
||||
{{-- 요약 카드 --}}
|
||||
@include('stats.dashboard.partials._summary-cards')
|
||||
|
||||
{{-- 추이 차트 --}}
|
||||
@include('stats.dashboard.partials._charts')
|
||||
|
||||
{{-- 하단 섹션 --}}
|
||||
@include('stats.dashboard.partials._bottom-section')
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const salesTrend = @json($salesTrend);
|
||||
const financeTrend = @json($financeTrend);
|
||||
const productionTrend = @json($productionTrend);
|
||||
const systemTrend = @json($systemTrend);
|
||||
|
||||
const defaultOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { usePointStyle: true, padding: 15, font: { size: 11 } }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { font: { size: 11 } }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: '#f3f4f6' },
|
||||
ticks: {
|
||||
font: { size: 11 },
|
||||
callback: function(value) {
|
||||
if (value >= 1000000) return (value / 1000000).toFixed(0) + 'M';
|
||||
if (value >= 1000) return (value / 1000).toFixed(0) + 'K';
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 매출 추이 (Bar + Line)
|
||||
new Chart(document.getElementById('salesChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: salesTrend.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '주문금액',
|
||||
data: salesTrend.datasets.order_amount,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.5)',
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
label: '매출액',
|
||||
data: salesTrend.datasets.sales_amount,
|
||||
type: 'line',
|
||||
borderColor: 'rgb(16, 185, 129)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: 'rgb(16, 185, 129)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
order: 1,
|
||||
}
|
||||
]
|
||||
},
|
||||
options: defaultOptions,
|
||||
});
|
||||
|
||||
// 자금 흐름 (Stacked Bar)
|
||||
new Chart(document.getElementById('financeChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: financeTrend.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '입금',
|
||||
data: financeTrend.datasets.deposit_amount,
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.7)',
|
||||
borderRadius: 4,
|
||||
},
|
||||
{
|
||||
label: '출금',
|
||||
data: financeTrend.datasets.withdrawal_amount.map(v => -v),
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.7)',
|
||||
borderRadius: 4,
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
...defaultOptions,
|
||||
scales: {
|
||||
...defaultOptions.scales,
|
||||
x: { ...defaultOptions.scales.x, stacked: true },
|
||||
y: {
|
||||
...defaultOptions.scales.y,
|
||||
stacked: true,
|
||||
beginAtZero: false,
|
||||
ticks: {
|
||||
...defaultOptions.scales.y.ticks,
|
||||
callback: function(value) {
|
||||
const abs = Math.abs(value);
|
||||
if (abs >= 1000000) return (value / 1000000).toFixed(0) + 'M';
|
||||
if (abs >= 1000) return (value / 1000).toFixed(0) + 'K';
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 생산 현황 (Dual axis Line)
|
||||
new Chart(document.getElementById('productionChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: productionTrend.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '생산수량',
|
||||
data: productionTrend.datasets.production_qty,
|
||||
borderColor: 'rgb(139, 92, 246)',
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: 'rgb(139, 92, 246)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
yAxisID: 'y',
|
||||
},
|
||||
{
|
||||
label: '불량률(%)',
|
||||
data: productionTrend.datasets.defect_rate,
|
||||
borderColor: 'rgb(239, 68, 68)',
|
||||
borderWidth: 2,
|
||||
borderDash: [5, 5],
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: 'rgb(239, 68, 68)',
|
||||
tension: 0.3,
|
||||
yAxisID: 'y1',
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
...defaultOptions,
|
||||
scales: {
|
||||
...defaultOptions.scales,
|
||||
y: {
|
||||
...defaultOptions.scales.y,
|
||||
position: 'left',
|
||||
title: { display: true, text: '생산수량', font: { size: 11 } },
|
||||
},
|
||||
y1: {
|
||||
position: 'right',
|
||||
beginAtZero: true,
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: {
|
||||
font: { size: 11 },
|
||||
callback: function(value) { return value + '%'; }
|
||||
},
|
||||
title: { display: true, text: '불량률(%)', font: { size: 11 } },
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 시스템 활동 (Line)
|
||||
new Chart(document.getElementById('systemChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: systemTrend.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '활성사용자',
|
||||
data: systemTrend.datasets.active_user_count,
|
||||
borderColor: 'rgb(99, 102, 241)',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: 'rgb(99, 102, 241)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
yAxisID: 'y',
|
||||
},
|
||||
{
|
||||
label: 'API 요청',
|
||||
data: systemTrend.datasets.api_request_count,
|
||||
borderColor: 'rgb(245, 158, 11)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: 'rgb(245, 158, 11)',
|
||||
tension: 0.3,
|
||||
yAxisID: 'y1',
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
...defaultOptions,
|
||||
scales: {
|
||||
...defaultOptions.scales,
|
||||
y: {
|
||||
...defaultOptions.scales.y,
|
||||
position: 'left',
|
||||
title: { display: true, text: '사용자', font: { size: 11 } },
|
||||
},
|
||||
y1: {
|
||||
position: 'right',
|
||||
beginAtZero: true,
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: {
|
||||
font: { size: 11 },
|
||||
callback: function(value) {
|
||||
if (value >= 1000) return (value / 1000).toFixed(0) + 'K';
|
||||
return value;
|
||||
}
|
||||
},
|
||||
title: { display: true, text: 'API 요청', font: { size: 11 } },
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,92 @@
|
||||
{{-- 하단 2컬럼 --}}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{{-- 좌: 최근 알림 --}}
|
||||
<div class="bg-white rounded-lg shadow p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">최근 알림</h3>
|
||||
<a href="{{ route('system.alerts.index') }}" class="text-sm text-blue-600 hover:underline">전체보기</a>
|
||||
</div>
|
||||
@if($recentAlerts->isEmpty())
|
||||
<div class="text-center py-8 text-gray-400">
|
||||
<svg class="w-10 h-10 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
||||
</svg>
|
||||
<p class="text-sm">미해결 알림이 없습니다</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach($recentAlerts as $alert)
|
||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-gray-50 border border-gray-100">
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full text-xs font-medium shrink-0 {{ $alert->severity_color }}">
|
||||
{{ mb_substr($alert->severity_label, 0, 1) }}
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-medium px-2 py-0.5 rounded bg-gray-200 text-gray-600">{{ $alert->domain_label }}</span>
|
||||
<span class="text-xs text-gray-400">{{ $alert->created_at?->diffForHumans() }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-800 truncate">{{ $alert->title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- 우: 이번 달 요약 --}}
|
||||
<div class="bg-white rounded-lg shadow p-5">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">이번 달 요약 ({{ $monthlySummary['month'] }})</h3>
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200">
|
||||
<th class="text-left py-2 font-medium text-gray-500">구분</th>
|
||||
<th class="text-right py-2 font-medium text-gray-500">항목</th>
|
||||
<th class="text-right py-2 font-medium text-gray-500">금액/수량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{{-- 매출 --}}
|
||||
<tr>
|
||||
<td class="py-2.5 text-gray-600" rowspan="3">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
매출
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2.5 text-right text-gray-600">주문건수</td>
|
||||
<td class="py-2.5 text-right font-medium text-gray-900">{{ number_format($monthlySummary['sales']['order_count']) }}건</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2.5 text-right text-gray-600">주문금액</td>
|
||||
<td class="py-2.5 text-right font-medium text-gray-900">{{ number_format($monthlySummary['sales']['order_amount']) }}원</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2.5 text-right text-gray-600">매출액</td>
|
||||
<td class="py-2.5 text-right font-medium text-gray-900">{{ number_format($monthlySummary['sales']['sales_amount']) }}원</td>
|
||||
</tr>
|
||||
|
||||
{{-- 재무 --}}
|
||||
<tr>
|
||||
<td class="py-2.5 text-gray-600" rowspan="3">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-blue-500"></span>
|
||||
재무
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2.5 text-right text-gray-600">입금</td>
|
||||
<td class="py-2.5 text-right font-medium text-green-600">{{ number_format($monthlySummary['finance']['deposit_total']) }}원</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2.5 text-right text-gray-600">출금</td>
|
||||
<td class="py-2.5 text-right font-medium text-red-600">{{ number_format($monthlySummary['finance']['withdrawal_total']) }}원</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2.5 text-right text-gray-600">순자금흐름</td>
|
||||
<td class="py-2.5 text-right font-bold {{ $monthlySummary['finance']['net_cashflow'] >= 0 ? 'text-green-600' : 'text-red-600' }}">
|
||||
{{ $monthlySummary['finance']['net_cashflow'] >= 0 ? '+' : '' }}{{ number_format($monthlySummary['finance']['net_cashflow']) }}원
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
34
resources/views/stats/dashboard/partials/_charts.blade.php
Normal file
34
resources/views/stats/dashboard/partials/_charts.blade.php
Normal file
@@ -0,0 +1,34 @@
|
||||
{{-- 추이 차트 2x2 그리드 --}}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
{{-- 매출 추이 --}}
|
||||
<div class="bg-white rounded-lg shadow p-5">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">매출 추이 (7일)</h3>
|
||||
<div class="relative" style="height: 280px;">
|
||||
<canvas id="salesChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 자금 흐름 --}}
|
||||
<div class="bg-white rounded-lg shadow p-5">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">자금 흐름 (7일)</h3>
|
||||
<div class="relative" style="height: 280px;">
|
||||
<canvas id="financeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 생산 현황 --}}
|
||||
<div class="bg-white rounded-lg shadow p-5">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">생산 현황 (7일)</h3>
|
||||
<div class="relative" style="height: 280px;">
|
||||
<canvas id="productionChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 시스템 활동 --}}
|
||||
<div class="bg-white rounded-lg shadow p-5">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">시스템 활동 (7일)</h3>
|
||||
<div class="relative" style="height: 280px;">
|
||||
<canvas id="systemChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
20
resources/views/stats/dashboard/partials/_delta.blade.php
Normal file
20
resources/views/stats/dashboard/partials/_delta.blade.php
Normal file
@@ -0,0 +1,20 @@
|
||||
@php
|
||||
$inverse = $inverse ?? false;
|
||||
$isPositive = $inverse ? ($value <= 0) : ($value >= 0);
|
||||
$absValue = abs($value);
|
||||
@endphp
|
||||
<div class="flex items-center mt-2 text-xs">
|
||||
@if($value != 0)
|
||||
<span class="{{ $isPositive ? 'text-green-600' : 'text-red-600' }} flex items-center">
|
||||
@if($value > 0)
|
||||
<svg class="w-3 h-3 mr-0.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
||||
@else
|
||||
<svg class="w-3 h-3 mr-0.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
@endif
|
||||
{{ number_format($absValue) }}{{ $suffix ?? '' }}
|
||||
</span>
|
||||
<span class="text-gray-400 ml-1">전일 대비</span>
|
||||
@else
|
||||
<span class="text-gray-400">변동 없음</span>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,93 @@
|
||||
{{-- 요약 카드 6개 --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4 mb-6">
|
||||
{{-- 오늘 주문 --}}
|
||||
<div class="bg-white rounded-lg shadow p-5 border-l-4 border-blue-500">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-gray-500">오늘 주문</h3>
|
||||
<span class="text-blue-500">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ number_format($summaryCards['orders']['count']) }}건</div>
|
||||
<div class="text-sm text-gray-500 mt-1">{{ number_format($summaryCards['orders']['amount']) }}원</div>
|
||||
@include('stats.dashboard.partials._delta', ['value' => $summaryCards['orders']['delta_count'], 'suffix' => '건'])
|
||||
</div>
|
||||
|
||||
{{-- 오늘 매출 --}}
|
||||
<div class="bg-white rounded-lg shadow p-5 border-l-4 border-green-500">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-gray-500">오늘 매출</h3>
|
||||
<span class="text-green-500">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ number_format($summaryCards['sales']['amount']) }}원</div>
|
||||
@include('stats.dashboard.partials._delta', ['value' => $summaryCards['sales']['delta'], 'suffix' => '원'])
|
||||
</div>
|
||||
|
||||
{{-- 순자금흐름 --}}
|
||||
<div class="bg-white rounded-lg shadow p-5 border-l-4 {{ $summaryCards['cashflow']['net'] >= 0 ? 'border-green-500' : 'border-red-500' }}">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-gray-500">순자금흐름</h3>
|
||||
<span class="{{ $summaryCards['cashflow']['net'] >= 0 ? 'text-green-500' : 'text-red-500' }}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold {{ $summaryCards['cashflow']['net'] >= 0 ? 'text-green-600' : 'text-red-600' }}">
|
||||
{{ $summaryCards['cashflow']['net'] >= 0 ? '+' : '' }}{{ number_format($summaryCards['cashflow']['net']) }}원
|
||||
</div>
|
||||
@include('stats.dashboard.partials._delta', ['value' => $summaryCards['cashflow']['delta'], 'suffix' => '원'])
|
||||
</div>
|
||||
|
||||
{{-- 생산 효율 --}}
|
||||
<div class="bg-white rounded-lg shadow p-5 border-l-4 border-purple-500">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-gray-500">생산 효율</h3>
|
||||
<span class="text-purple-500">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ number_format($summaryCards['production']['efficiency'], 1) }}%</div>
|
||||
<div class="text-sm text-gray-500 mt-1">불량률 {{ number_format($summaryCards['production']['defect_rate'], 1) }}%</div>
|
||||
@include('stats.dashboard.partials._delta', ['value' => $summaryCards['production']['delta_efficiency'], 'suffix' => '%p'])
|
||||
</div>
|
||||
|
||||
{{-- 재고 경고 --}}
|
||||
<div class="bg-white rounded-lg shadow p-5 border-l-4 border-yellow-500">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-gray-500">재고 경고</h3>
|
||||
<span class="text-yellow-500">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold {{ $summaryCards['inventory']['below_safety'] > 0 ? 'text-yellow-600' : 'text-gray-900' }}">
|
||||
{{ number_format($summaryCards['inventory']['below_safety']) }}건
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 mt-1">안전재고 미달</div>
|
||||
@include('stats.dashboard.partials._delta', ['value' => $summaryCards['inventory']['delta'], 'suffix' => '건', 'inverse' => true])
|
||||
</div>
|
||||
|
||||
{{-- 활성 사용자 --}}
|
||||
<div class="bg-white rounded-lg shadow p-5 border-l-4 border-indigo-500">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-gray-500">활성 사용자</h3>
|
||||
<span class="text-indigo-500">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ number_format($summaryCards['system']['active_users']) }}명</div>
|
||||
@include('stats.dashboard.partials._delta', ['value' => $summaryCards['system']['delta'], 'suffix' => '명'])
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,6 +34,7 @@
|
||||
use App\Http\Controllers\RolePermissionController;
|
||||
use App\Http\Controllers\Sales\SalesProductController;
|
||||
use App\Http\Controllers\System\AiConfigController;
|
||||
use App\Http\Controllers\Stats\StatDashboardController;
|
||||
use App\Http\Controllers\System\SystemAlertController;
|
||||
use App\Http\Controllers\TenantController;
|
||||
use App\Http\Controllers\TenantSettingController;
|
||||
@@ -369,6 +370,9 @@
|
||||
Route::delete('/{id}/force-delete', [AppVersionController::class, 'forceDestroy'])->name('force-destroy');
|
||||
});
|
||||
|
||||
// 통계 대시보드
|
||||
Route::get('/stats/dashboard', [StatDashboardController::class, 'index'])->name('stats.dashboard');
|
||||
|
||||
// 시스템 알림 관리
|
||||
Route::prefix('system/alerts')->name('system.alerts.')->group(function () {
|
||||
Route::get('/', [SystemAlertController::class, 'index'])->name('index');
|
||||
|
||||
Reference in New Issue
Block a user