Initial commit: OAuth IAM Laravel application
Laravel-based OAuth/IAM service with Passport, Vue 3 frontend, and Docker Compose setup for local and production deployment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
45
resources/js/components/charts/BarList.vue
Normal file
45
resources/js/components/charts/BarList.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface BarItem {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
items: BarItem[];
|
||||
emptyText?: string;
|
||||
}>(),
|
||||
{
|
||||
emptyText: 'Sin datos en este período.',
|
||||
},
|
||||
);
|
||||
|
||||
const max = computed(() => Math.max(1, ...props.items.map((i) => i.value)));
|
||||
|
||||
function widthPct(value: number): number {
|
||||
return Math.max(2, Math.round((value / max.value) * 100));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bar-list">
|
||||
<h3 class="bar-list__title">{{ title }}</h3>
|
||||
|
||||
<p v-if="!items.length" class="bar-list__empty">{{ emptyText }}</p>
|
||||
|
||||
<ul v-else class="bar-list__items">
|
||||
<li v-for="item in items" :key="item.label" class="bar-list__row" :title="`${item.label}: ${item.value}`">
|
||||
<div class="bar-list__row-head">
|
||||
<span class="bar-list__label">{{ item.label }}</span>
|
||||
<span class="bar-list__value">{{ item.value }}</span>
|
||||
</div>
|
||||
<div class="bar-list__track">
|
||||
<div class="bar-list__fill" :style="{ width: widthPct(item.value) + '%' }" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
328
resources/js/components/charts/DailyLoginsChart.vue
Normal file
328
resources/js/components/charts/DailyLoginsChart.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
interface DailyPoint {
|
||||
date: string;
|
||||
session: number;
|
||||
oauth: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
data: DailyPoint[];
|
||||
}>();
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const width = 720;
|
||||
const height = 220;
|
||||
const padLeft = 34;
|
||||
const padBottom = 22;
|
||||
const padTop = 10;
|
||||
const plotW = width - padLeft - 8;
|
||||
const plotH = height - padTop - padBottom;
|
||||
|
||||
const maxTotal = computed(() => Math.max(4, ...props.data.map((d) => d.session + d.oauth)));
|
||||
|
||||
const niceMax = computed(() => {
|
||||
// redondea el techo del eje Y a un número "lindo"
|
||||
const raw = maxTotal.value;
|
||||
const magnitude = Math.pow(10, Math.floor(Math.log10(raw || 1)));
|
||||
const step = Math.ceil(raw / magnitude) * magnitude;
|
||||
|
||||
return step <= 0 ? 4 : step;
|
||||
});
|
||||
|
||||
const yTicks = computed(() => [0, 0.5, 1].map((f) => Math.round(niceMax.value * f)));
|
||||
|
||||
const barW = computed(() => Math.max(2, plotW / props.data.length - 2));
|
||||
|
||||
function x(i: number): number {
|
||||
return padLeft + (i * plotW) / props.data.length;
|
||||
}
|
||||
function yFor(value: number): number {
|
||||
return padTop + plotH - (value / niceMax.value) * plotH;
|
||||
}
|
||||
|
||||
// Rect con SOLO las puntas de arriba redondeadas (4px) y la base cuadrada,
|
||||
// para que el extremo libre del stack se redondee pero la base (donde se
|
||||
// apoya en el baseline, o en el segmento de abajo) quede a escuadra.
|
||||
function topRoundedRect(xPos: number, y: number, w: number, h: number): string {
|
||||
const r = Math.min(4, w / 2, Math.max(0, h));
|
||||
if (h <= 0) return '';
|
||||
if (r <= 0) return `M${xPos},${y} h${w} v${h} h${-w} Z`;
|
||||
|
||||
return [
|
||||
`M${xPos},${y + h}`,
|
||||
`V${y + r}`,
|
||||
`Q${xPos},${y} ${xPos + r},${y}`,
|
||||
`H${xPos + w - r}`,
|
||||
`Q${xPos + w},${y} ${xPos + w},${y + r}`,
|
||||
`V${y + h}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
const hovered = ref<number | null>(null);
|
||||
|
||||
function labelFor(i: number): string {
|
||||
const d = props.data[i];
|
||||
if (!d) return '';
|
||||
const dt = new Date(d.date + 'T00:00:00');
|
||||
|
||||
return dt.toLocaleDateString(locale.value, { day: '2-digit', month: 'short' });
|
||||
}
|
||||
|
||||
function ariaLabelFor(i: number): string {
|
||||
const d = props.data[i];
|
||||
if (!d) return '';
|
||||
|
||||
return t('analytics.barAriaLabel', { date: labelFor(i), oauth: d.oauth, session: d.session });
|
||||
}
|
||||
|
||||
const showEveryNth = computed(() => Math.ceil(props.data.length / 6));
|
||||
|
||||
const tooltipStyle = computed(() => {
|
||||
if (hovered.value === null) return { display: 'none' };
|
||||
const left = x(hovered.value) + barW.value / 2;
|
||||
const clampedLeft = Math.min(Math.max(left, 70), width - 70);
|
||||
|
||||
return {
|
||||
left: (clampedLeft / width) * 100 + '%',
|
||||
top: '4px',
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="daily-chart">
|
||||
<div class="daily-chart__header">
|
||||
<h3 class="daily-chart__title">{{ t('analytics.loginsByDay') }}</h3>
|
||||
<div class="daily-chart__legend">
|
||||
<span class="daily-chart__legend-item"><span class="daily-chart__swatch daily-chart__swatch--oauth"></span> {{ t('analytics.appsOAuth') }}</span>
|
||||
<span class="daily-chart__legend-item"><span class="daily-chart__swatch daily-chart__swatch--session"></span> {{ t('analytics.panelAdmin') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="daily-chart__body">
|
||||
<svg :viewBox="`0 0 ${width} ${height}`" class="daily-chart__svg" role="img" :aria-label="t('analytics.chartAriaLabel')">
|
||||
<!-- gridlines -->
|
||||
<g>
|
||||
<line
|
||||
v-for="tick in yTicks"
|
||||
:key="tick"
|
||||
:x1="padLeft"
|
||||
:x2="width - 4"
|
||||
:y1="yFor(tick)"
|
||||
:y2="yFor(tick)"
|
||||
class="daily-chart__gridline"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<text v-for="tick in yTicks" :key="'l' + tick" :x="padLeft - 6" :y="yFor(tick) + 3" text-anchor="end" class="daily-chart__axis-text" font-size="9">
|
||||
{{ tick }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- bars -->
|
||||
<g v-for="(d, i) in data" :key="d.date">
|
||||
<!-- oauth segment (bottom, apoyado en el baseline -> cuadrado).
|
||||
Si no hay segmento de sesión arriba, esta punta es la libre: se redondea. -->
|
||||
<path
|
||||
v-if="d.session > 0"
|
||||
:d="`M${x(i)},${yFor(0)} h${barW} v${-(Math.max(0, yFor(0) - yFor(d.oauth)))} h${-barW} Z`"
|
||||
class="daily-chart__bar daily-chart__bar--oauth"
|
||||
:opacity="hovered === null || hovered === i ? 1 : 0.35"
|
||||
/>
|
||||
<path
|
||||
v-else
|
||||
:d="topRoundedRect(x(i), yFor(d.oauth), barW, Math.max(0, yFor(0) - yFor(d.oauth)))"
|
||||
class="daily-chart__bar daily-chart__bar--oauth"
|
||||
:opacity="hovered === null || hovered === i ? 1 : 0.35"
|
||||
/>
|
||||
<!-- session segment (stacked on top, 2px surface gap, punta libre redondeada) -->
|
||||
<path
|
||||
v-if="d.session > 0"
|
||||
:d="topRoundedRect(x(i), yFor(d.oauth + d.session), barW, Math.max(0, yFor(d.oauth) - yFor(d.oauth + d.session) - 2))"
|
||||
class="daily-chart__bar daily-chart__bar--session"
|
||||
:opacity="hovered === null || hovered === i ? 1 : 0.35"
|
||||
/>
|
||||
<!-- hit target: toda la columna, más ancho que la barra visible -->
|
||||
<rect
|
||||
:x="x(i) - 1"
|
||||
:y="padTop"
|
||||
:width="barW + 2"
|
||||
:height="plotH"
|
||||
class="daily-chart__hit"
|
||||
@mouseenter="hovered = i"
|
||||
@mouseleave="hovered = null"
|
||||
@focus="hovered = i"
|
||||
@blur="hovered = null"
|
||||
tabindex="0"
|
||||
:aria-label="ariaLabelFor(i)"
|
||||
/>
|
||||
<text
|
||||
v-if="i % showEveryNth === 0"
|
||||
:x="x(i) + barW / 2"
|
||||
:y="height - 4"
|
||||
text-anchor="middle"
|
||||
class="daily-chart__axis-text"
|
||||
font-size="9"
|
||||
>
|
||||
{{ labelFor(i) }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- baseline -->
|
||||
<line :x1="padLeft" :x2="width - 4" :y1="yFor(0)" :y2="yFor(0)" class="daily-chart__baseline" stroke-width="1" />
|
||||
</svg>
|
||||
|
||||
<div v-if="hovered !== null" class="daily-chart__tooltip" :style="tooltipStyle">
|
||||
<p class="daily-chart__tooltip-date">{{ labelFor(hovered) }}</p>
|
||||
<p class="daily-chart__tooltip-row">
|
||||
<span class="daily-chart__swatch daily-chart__swatch--oauth daily-chart__swatch--sm"></span>
|
||||
{{ t('analytics.tooltipAppLabel') }} <strong>{{ data[hovered].oauth }}</strong>
|
||||
</p>
|
||||
<p class="daily-chart__tooltip-row">
|
||||
<span class="daily-chart__swatch daily-chart__swatch--session daily-chart__swatch--sm"></span>
|
||||
{{ t('analytics.tooltipSessionLabel') }} <strong>{{ data[hovered].session }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// Este componente usa <style scoped>, así que el mixin "dark" global (pensado
|
||||
// para partials no-scoped) no aplica acá: hace falta ":global(.dark) &"
|
||||
// directo para atravesar el compilador scoped de Vue.
|
||||
.daily-chart {
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
--muted: #898781;
|
||||
--gridline: #e1e0d9;
|
||||
--axis: #c3c2b7;
|
||||
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
|
||||
:global(.dark) & {
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--gridline: #2c2c2a;
|
||||
--axis: #383835;
|
||||
}
|
||||
}
|
||||
|
||||
.daily-chart__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.daily-chart__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.daily-chart__legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.daily-chart__legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.daily-chart__swatch {
|
||||
display: inline-block;
|
||||
height: 0.5rem;
|
||||
width: 0.75rem;
|
||||
border-radius: 0.125rem;
|
||||
|
||||
&--oauth {
|
||||
background: var(--series-1);
|
||||
}
|
||||
|
||||
&--session {
|
||||
background: var(--series-2);
|
||||
}
|
||||
|
||||
&--sm {
|
||||
height: 0.5rem;
|
||||
width: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.daily-chart__body {
|
||||
position: relative;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.daily-chart__svg {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.daily-chart__gridline {
|
||||
stroke: var(--gridline);
|
||||
}
|
||||
|
||||
.daily-chart__baseline {
|
||||
stroke: var(--axis);
|
||||
}
|
||||
|
||||
.daily-chart__axis-text {
|
||||
fill: var(--muted);
|
||||
}
|
||||
|
||||
.daily-chart__bar {
|
||||
&--oauth {
|
||||
fill: var(--series-1);
|
||||
}
|
||||
|
||||
&--session {
|
||||
fill: var(--series-2);
|
||||
}
|
||||
}
|
||||
|
||||
.daily-chart__hit {
|
||||
fill: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.daily-chart__tooltip {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
|
||||
.daily-chart__tooltip-date {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.daily-chart__tooltip-row {
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
strong {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
resources/js/components/charts/StatTile.vue
Normal file
15
resources/js/components/charts/StatTile.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string;
|
||||
value: string | number;
|
||||
hint?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stat-tile">
|
||||
<p class="stat-tile__label">{{ label }}</p>
|
||||
<p class="stat-tile__value">{{ value }}</p>
|
||||
<p v-if="hint" class="stat-tile__hint">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user