falak-app-duo 1.3.1 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,8 @@ integrations on the frontend side: **Ably** (realtime) and **Brevo**
15
15
  | Laravel API auth | `X-API-Key` (app) + `Authorization: Bearer <token>` (user) |
16
16
  | AES-256 | Kept — encrypts the cached user token in IndexedDB |
17
17
  | Arabic/RTL | Kept — baked in via vue-i18n + postcss-rtlcss (opt-out: `--no-rtl` or the RTL prompt) |
18
+ | Admin dashboard | **New** — role-tiered admin UI at `/dashboard` (users, app entities, overview stats, app settings) |
19
+ | Demo mode | Optional — run the whole app in-browser on bundled demo data (`VITE_DATA_MODE=demo`) against an IndexedDB mimic of the Laravel API, no backend needed |
18
20
 
19
21
  ## Install & run
20
22
 
@@ -23,8 +25,9 @@ No install needed — run it straight with `npx`:
23
25
 
24
26
  ```bash
25
27
  npx falak-app-duo my-project # interactive prompts
26
- npx falak-app-duo my-project --yes # all defaults: Ably + Brevo, localhost API, RTL on
28
+ npx falak-app-duo my-project --yes # all defaults: Ably + Brevo, localhost API, RTL on, no roles/demo
27
29
  npx falak-app-duo my-project --no-rtl # default locale becomes English/LTR
30
+ npx falak-app-duo my-project --roles master,super_admin,admin,user --demo-accounts --demo-data products,orders
28
31
  ```
29
32
 
30
33
  Or install the CLI globally once:
@@ -36,6 +39,38 @@ falak-app-duo my-project
36
39
 
37
40
  The org-scoped equivalent is `npx @ma7moudsalama/falak-app-duo my-project`.
38
41
 
42
+ Feature flags:
43
+
44
+ - `--roles <list>` — enable UserRole support for the given comma-separated
45
+ roles (`master,super_admin,admin,user`). Any of `--roles`/`--demo-accounts`/
46
+ `--demo-data` skips the interactive prompts and uses defaults for the rest.
47
+ - `--demo-accounts` — ship one static demo login per selected role (or a plain
48
+ demo user when roles are off) that works immediately on the scratch app.
49
+ - `--demo-data <list>` — include static demo rows for the given entities
50
+ (e.g. `products,orders`); the demo backend then serves full CRUD for them.
51
+
52
+ Roles gate the admin dashboard by tier — `user:0 < admin:1 < super_admin:2 <
53
+ master:3`:
54
+
55
+ - Overview + entity CRUD tabs: any admin (tier ≥ 1).
56
+ - Users + app-settings tabs (`/dashboard`): master/super_admin (tier ≥ 2).
57
+ - Deleting accounts: master only (tier ≥ 2 can edit roles/status, never their
58
+ own account or a higher-tier one).
59
+
60
+ ## Demo mode
61
+
62
+ When demo features are selected (demo accounts and/or demo data), `.env` sets
63
+ `VITE_DATA_MODE=demo`. The app then stops calling your API
64
+ and runs entirely in-browser against `src/services/demoApi.js`, which mimics
65
+ the Laravel backend in an IndexedDB database (`falak_demo_db`): a full sign-up/
66
+ sign-in flow, profile + settings writes, the Home `/overview` stats, generic
67
+ entity CRUD, and the same `/admin/*` routes the dashboard panel calls — all
68
+ behind the same `Authorization: Bearer` + admin-tier checks. Reuses login info
69
+ stored in the DB until the seed fingerprint changes. Drop the same frontend on
70
+ a real backend by setting `VITE_DATA_MODE=live` (the default without demo
71
+ features). Client code talks to the data layer through one import,
72
+ `@/services/dataClient`, so swapping modes is a single env-var flip.
73
+
39
74
  ## Auth model
40
75
 
41
76
  Every request from `src/services/api.js` carries two headers:
@@ -84,11 +119,17 @@ nothing to scaffold on the client side for it.
84
119
  ## Backend routes this frontend expects
85
120
 
86
121
  ```
87
- POST /api/auth/login -> { token, user }
122
+ POST /api/auth/login -> { token, user }
123
+ POST /api/auth/register -> { token, user } (no role sent — backend assigns default)
88
124
  POST /api/auth/logout
89
- GET /api/auth/me -> user
125
+ GET /api/auth/me -> user
90
126
  POST /api/broadcasting/auth (Ably/Echo channel authorization)
91
127
  POST /api/brevo/email | /api/brevo/sms | /api/brevo/contacts
128
+ GET /api/admin/overview (admin tier ≥ 1)
129
+ GET /api/admin/users (tier ≥ 2)
130
+ PUT /api/admin/users/:id (tier ≥ 2; role/status edits, no self/higher-tier)
131
+ DELETE /api/admin/users/:id (tier ≥ 3, master only)
132
+ GET /api/admin/settings | PUT /api/admin/settings (tier ≥ 2)
92
133
  ```
93
134
  plus an `X-API-Key` middleware gating all of the above, and Sanctum (or
94
135
  similar) validating the bearer token for user-scoped routes.
@@ -97,12 +138,17 @@ similar) validating the bearer token for user-scoped routes.
97
138
 
98
139
  1. Prints a gradient **Falak Duo** banner.
99
140
  2. In interactive mode asks which services to wire up (**Ably** and/or
100
- **Brevo**), the Laravel API base URL, and whether to enable **Arabic/RTL**
101
- by default. `--yes` takes all defaults; `--no-rtl` skips the RTL choice and
102
- defaults to English/LTR.
103
- 3. Copies `templates/default` into `my-project/`, strips the service files you
104
- didn't select, and generates a real `.env` from `.env.example` with a fresh
105
- random AES-256 key + IV and your chosen API base URL.
141
+ **Brevo**), the Laravel API base URL, whether to enable **Arabic/RTL**
142
+ by default, and which optional extras to include (**UserRole** support,
143
+ **demo accounts**, **demo data** entities). `--yes` takes all defaults
144
+ (no roles/demo); `--no-rtl` skips the RTL choice and defaults to
145
+ English/LTR; any feature flag skips the prompts.
146
+ 3. Copies `templates/default` into `my-project/`, strips the service files and
147
+ demo-data modules you didn't select, slices the app to your chosen
148
+ languages, rewrites the role set, and generates a real `.env` from
149
+ `.env.example` with a fresh random AES-256 key + IV, your chosen API base
150
+ URL, and `VITE_DATA_MODE` (`demo` when demo accounts/data are on, else
151
+ `live`).
106
152
  4. Offers to run `npm install` and start the dev server for you (`--yes` runs
107
153
  `npm install` automatically).
108
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "falak-app-duo",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "Vue 3 boilerplate scaffolder — Ably (realtime), Brevo, AES-256, Arabic/RTL, and a Laravel API client (API key + user token auth). No Firebase, no Groq/Paymob on the frontend (Groq is backend-only; Paymob dropped).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,10 +1,11 @@
1
1
  <script setup>
2
- import { computed, ref } from "vue";
2
+ import { computed, onMounted, ref } from "vue";
3
3
  import { useI18n } from "vue-i18n";
4
4
  import { useRoute, useRouter, RouterLink, RouterView } from "vue-router";
5
5
  import { setLocale, SUPPORTED_LOCALES } from "@/i18n";
6
6
  import { useAuthStore } from "@/stores/auth";
7
7
  import { getTheme, toggleTheme } from "@/theme";
8
+ import { isAdmin } from "@/utils/access";
8
9
  import AppBackdrop from "@/components/AppBackdrop.vue";
9
10
 
10
11
  const { t, locale } = useI18n();
@@ -18,10 +19,24 @@ const theme = ref(getTheme());
18
19
  const authPageNames = ["login", "register", "forgot-password"];
19
20
  const isAuthPage = computed(() => authPageNames.includes(route.name));
20
21
 
21
- const links = computed(() => [
22
- { to: "/profile", label: t("nav.profile") },
23
- { to: "/settings", label: t("nav.settings") },
24
- ]);
22
+ const links = computed(() => {
23
+ const items = [
24
+ { to: "/profile", label: t("nav.profile") },
25
+ { to: "/settings", label: t("nav.settings") },
26
+ ];
27
+ if (auth.user && isAdmin(auth.user.role)) {
28
+ items.unshift({ to: "/dashboard", label: t("nav.dashboard") });
29
+ }
30
+ return items;
31
+ });
32
+
33
+ // A logged-in user may land straight on a protected route (refresh): hydrate
34
+ // the account so the nav shows role-gated links (like Dashboard).
35
+ onMounted(() => {
36
+ if (auth.userToken && !auth.user) {
37
+ auth.fetchProfile().catch(() => {});
38
+ }
39
+ });
25
40
 
26
41
  async function logout() {
27
42
  await auth.logout();
@@ -0,0 +1,288 @@
1
+ <script setup>
2
+ import { onMounted, ref, watch } from "vue";
3
+ import { useI18n } from "vue-i18n";
4
+ import { client } from "@/services/dataClient";
5
+ import GlassCard from "@/components/GlassCard.vue";
6
+
7
+ const { t } = useI18n();
8
+
9
+ const entities = ref([]);
10
+ const entity = ref("");
11
+ const rows = ref([]);
12
+ const loading = ref(true);
13
+ const error = ref("");
14
+ const message = ref("");
15
+ const editorOpen = ref(false);
16
+ const editing = ref(null); // null = new row, otherwise the record being edited
17
+ const draft = ref({});
18
+ const draftKeys = ref([]);
19
+
20
+ watch(entity, async (name) => {
21
+ if (!name) {
22
+ rows.value = [];
23
+ return;
24
+ }
25
+ await loadRows(name);
26
+ });
27
+
28
+ async function loadEntityList() {
29
+ try {
30
+ const { data } = await client.get("/admin/overview");
31
+ entities.value = data.entities || [];
32
+ // Prefer a non-auxiliary entity; fall back to the first one.
33
+ entity.value = entities.value.find((e) => e.name !== "users")?.name || entities.value[0]?.name || "";
34
+ } catch (e) {
35
+ error.value = t("dashboard.entities.error");
36
+ }
37
+ }
38
+
39
+ async function loadRows(name) {
40
+ loading.value = true;
41
+ error.value = "";
42
+ message.value = "";
43
+ try {
44
+ const { data } = await client.get(`/${name}`);
45
+ rows.value = data;
46
+ if (editing.value || draft.value) resetEditor();
47
+ } catch (e) {
48
+ error.value = t("dashboard.entities.error");
49
+ } finally {
50
+ loading.value = false;
51
+ }
52
+ }
53
+
54
+ // Enumerate editable columns from the first record; exclude `id` (managed by
55
+ // the backend) and long free-text blobs are fine to keep — the editor falls
56
+ // back to a textarea for anything that looks like a description.
57
+ function columnsOf(records) {
58
+ if (!records?.length) return [];
59
+ return Object.keys(records[0]).filter((k) => k !== "id");
60
+ }
61
+
62
+ function openNew() {
63
+ const cols = columnsOf(rows.value);
64
+ editing.value = null;
65
+ draft.value = Object.fromEntries(cols.map((c) => [c, ""]));
66
+ draftKeys.value = cols;
67
+ editorOpen.value = true;
68
+ }
69
+
70
+ function openEdit(record) {
71
+ editing.value = record;
72
+ const cols = columnsOf(rows.value);
73
+ draft.value = { ...record };
74
+ draftKeys.value = cols;
75
+ editorOpen.value = true;
76
+ }
77
+
78
+ function resetEditor() {
79
+ editing.value = null;
80
+ draft.value = {};
81
+ draftKeys.value = [];
82
+ }
83
+
84
+ async function saveDraft() {
85
+ error.value = "";
86
+ message.value = "";
87
+ try {
88
+ const url = `/${entity.value}${editing.value ? `/${editing.value.id}` : ""}`;
89
+ const res = editing.value ? await client.put(url, draft.value) : await client.post(url, draft.value);
90
+ message.value = editing.value ? t("dashboard.entities.saved") : t("dashboard.entities.created");
91
+ editorOpen.value = false;
92
+ resetEditor();
93
+ await loadRows(entity.value);
94
+ } catch (e) {
95
+ error.value = e?.response?.data?.message || t("dashboard.entities.error");
96
+ }
97
+ }
98
+
99
+ async function removeEntity(row) {
100
+ error.value = "";
101
+ message.value = "";
102
+ try {
103
+ await client.delete(`/${entity.value}/${row.id}`);
104
+ rows.value = rows.value.filter((r) => r.id !== row.id);
105
+ message.value = t("dashboard.entities.deleted");
106
+ } catch (e) {
107
+ error.value = e?.response?.data?.message || t("dashboard.entities.error");
108
+ }
109
+ }
110
+
111
+ function displayValue(record, key) {
112
+ const v = record[key];
113
+ if (v === null || v === undefined) return "—";
114
+ if (typeof v === "object") return JSON.stringify(v);
115
+ return String(v);
116
+ }
117
+
118
+ onMounted(loadEntityList);
119
+ </script>
120
+
121
+ <template>
122
+ <div>
123
+ <p v-if="message" class="mb-4 rounded-xl border border-emerald-400/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-300">
124
+ {{ message }}
125
+ </p>
126
+ <p v-if="error" class="mb-4 rounded-xl border border-rose-400/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-300">
127
+ {{ error }}
128
+ </p>
129
+
130
+ <GlassCard class="p-5">
131
+ <div class="flex flex-wrap items-center justify-between gap-3">
132
+ <h2 class="text-base font-bold text-ink">{{ t("dashboard.entities.title") }}</h2>
133
+ <div class="flex items-center gap-2">
134
+ <select
135
+ v-model="entity"
136
+ class="rounded-xl border border-edge2 bg-glass px-3.5 py-2 text-sm font-medium text-ink outline-none transition focus:ring-1 focus:ring-emerald-300/40"
137
+ >
138
+ <option v-for="e in entities" :key="e.name" :value="e.name">{{ e.name }}</option>
139
+ </select>
140
+ <button
141
+ type="button"
142
+ class="btn-primary !px-4 !py-2"
143
+ :disabled="!entity"
144
+ @click="openNew"
145
+ >
146
+ {{ t("dashboard.entities.add") }}
147
+ </button>
148
+ </div>
149
+ </div>
150
+
151
+ <p class="mt-1.5 text-xs text-mute">{{ t("dashboard.entities.subtitle") }}</p>
152
+
153
+ <div class="mt-4 overflow-x-auto">
154
+ <table class="w-full min-w-[560px] border-collapse text-left text-sm rtl:text-right">
155
+ <thead>
156
+ <tr class="border-b border-edge text-xs uppercase tracking-wide text-mute">
157
+ <th class="pb-2 pe-3 font-semibold">#</th>
158
+ <th v-for="key in columnsOf(rows)" :key="key" class="pb-2 pe-3 font-semibold">{{ key }}</th>
159
+ <th class="pb-2 font-semibold">{{ t("dashboard.entities.actions") }}</th>
160
+ </tr>
161
+ </thead>
162
+ <tbody>
163
+ <tr v-if="loading">
164
+ <td :colspan="columnsOf(rows).length + 2" class="py-8 text-center text-mute">{{ t("common.loading") }}</td>
165
+ </tr>
166
+ <tr v-else-if="rows.length === 0">
167
+ <td :colspan="columnsOf(rows).length + 2" class="py-8 text-center text-mute">{{ t("dashboard.entities.empty") }}</td>
168
+ </tr>
169
+ <tr v-for="(row, i) in rows" :key="row.id" class="border-b border-edge/60 last:border-0">
170
+ <td class="py-3 pe-3 font-mono text-xs text-mute">{{ i + 1 }}</td>
171
+ <td v-for="key in columnsOf(rows)" :key="key" class="max-w-[200px] truncate py-3 pe-3 text-mute" :title="displayValue(row, key)">
172
+ {{ displayValue(row, key) }}
173
+ </td>
174
+ <td class="py-3">
175
+ <div class="flex items-center gap-2">
176
+ <button
177
+ type="button"
178
+ class="rounded-lg px-3 py-1.5 text-xs font-semibold text-cyan-300 ring-1 ring-cyan-400/30 transition hover:bg-cyan-500/10"
179
+ @click="openEdit(row)"
180
+ >
181
+ {{ t("dashboard.entities.edit") }}
182
+ </button>
183
+ <button
184
+ type="button"
185
+ class="rounded-lg px-3 py-1.5 text-xs font-semibold text-rose-300 ring-1 ring-rose-400/30 transition hover:bg-rose-500/10"
186
+ @click="removeEntity(row)"
187
+ >
188
+ {{ t("dashboard.entities.delete") }}
189
+ </button>
190
+ </div>
191
+ </td>
192
+ </tr>
193
+ </tbody>
194
+ </table>
195
+ </div>
196
+ </GlassCard>
197
+
198
+ <!-- Row editor modal -->
199
+ <Teleport to="body">
200
+ <Transition name="sd" appear>
201
+ <div v-if="editorOpen" class="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-6">
202
+ <div class="sd-backdrop absolute inset-0 bg-[#05070d]/80 backdrop-blur-md" @click="editorOpen = false"></div>
203
+
204
+ <div class="sd-panel relative z-10 flex max-h-[92vh] w-full max-w-xl flex-col overflow-hidden rounded-t-3xl border border-edge bg-page/95 shadow-2xl shadow-black/60 backdrop-blur-2xl sm:rounded-3xl">
205
+ <div class="flex items-start justify-between gap-4 border-b border-edge px-5 py-4 sm:px-6">
206
+ <div>
207
+ <h2 class="text-lg font-bold text-ink">
208
+ {{ editing ? t("dashboard.entities.edit") : t("dashboard.entities.add") }}
209
+ <span class="font-mono text-sm text-teal-300">/{{ entity }}/{{ editing ? editing.id : "new" }}</span>
210
+ </h2>
211
+ </div>
212
+ <button
213
+ type="button"
214
+ class="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-edge bg-glass text-mute transition hover:bg-glass3 hover:text-ink"
215
+ :aria-label="t('common.cancel')"
216
+ @click="editorOpen = false"
217
+ >
218
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
219
+ <path d="M18 6 6 18M6 6l12 12" />
220
+ </svg>
221
+ </button>
222
+ </div>
223
+
224
+ <div class="overflow-y-auto overscroll-contain px-5 py-5 sm:px-6 sm:py-6">
225
+ <div v-if="draftKeys.length === 0" class="text-sm text-mute">{{ t("dashboard.entities.empty") }}</div>
226
+ <div v-else class="space-y-4">
227
+ <label v-for="key in draftKeys" :key="key" class="block">
228
+ <span class="mb-1 block font-mono text-xs font-semibold uppercase tracking-wide text-mute">{{ key }}</span>
229
+ <textarea
230
+ v-if="String(draft[key]).length > 60 || typeof draft[key] === 'object'"
231
+ v-model="draft[key]"
232
+ rows="2"
233
+ class="w-full rounded-xl border border-edge2 bg-glass px-3.5 py-2 text-sm text-ink outline-none transition placeholder:text-mute focus:ring-1 focus:ring-emerald-300/40"
234
+ ></textarea>
235
+ <input
236
+ v-else
237
+ v-model="draft[key]"
238
+ type="text"
239
+ class="w-full rounded-xl border border-edge2 bg-glass px-3.5 py-2 text-sm text-ink outline-none transition placeholder:text-mute focus:ring-1 focus:ring-emerald-300/40"
240
+ />
241
+ </label>
242
+
243
+ <div class="flex justify-end gap-2 pt-2">
244
+ <button type="button" class="btn-ghost" @click="editorOpen = false">{{ t("common.cancel") }}</button>
245
+ <button type="button" class="btn-primary" @click="saveDraft">{{ t("common.save") }}</button>
246
+ </div>
247
+ </div>
248
+ </div>
249
+ </div>
250
+ </div>
251
+ </Transition>
252
+ </Teleport>
253
+ </div>
254
+ </template>
255
+
256
+ <style scoped>
257
+ .sd-enter-active,
258
+ .sd-leave-active {
259
+ transition: opacity 0.25s ease;
260
+ }
261
+ .sd-enter-from,
262
+ .sd-leave-to {
263
+ opacity: 0;
264
+ }
265
+ .sd-backdrop {
266
+ transition: opacity 0.25s ease;
267
+ }
268
+ .sd-enter-from .sd-backdrop,
269
+ .sd-leave-to .sd-backdrop {
270
+ opacity: 0;
271
+ }
272
+ .sd-panel {
273
+ transition: opacity 0.25s ease, transform 0.25s ease;
274
+ }
275
+ .sd-enter-from .sd-panel,
276
+ .sd-leave-to .sd-panel {
277
+ opacity: 0;
278
+ transform: translateY(24px);
279
+ }
280
+ @media (prefers-reduced-motion: reduce) {
281
+ .sd-enter-active,
282
+ .sd-leave-active,
283
+ .sd-backdrop,
284
+ .sd-panel {
285
+ transition: none;
286
+ }
287
+ }
288
+ </style>
@@ -0,0 +1,186 @@
1
+ <script setup>
2
+ import { computed, onMounted, ref } from "vue";
3
+ import { useI18n } from "vue-i18n";
4
+ import { useAuthStore } from "@/stores/auth";
5
+ import { useAdminStore } from "@/stores/admin";
6
+ import GlassCard from "@/components/GlassCard.vue";
7
+ import { ROLES } from "@/data/roles";
8
+
9
+ const { t } = useI18n();
10
+ const auth = useAuthStore();
11
+ const admin = useAdminStore();
12
+
13
+ const overview = ref(null);
14
+ const loading = ref(true);
15
+ const error = ref("");
16
+
17
+ const stats = computed(() => [
18
+ { label: t("dashboard.overview.stats.users"), value: overview.value?.stats?.users ?? "—" },
19
+ { label: t("dashboard.overview.stats.orders"), value: overview.value?.stats?.orders ?? "—" },
20
+ { label: t("dashboard.overview.stats.products"), value: overview.value?.stats?.products ?? "—" },
21
+ { label: t("dashboard.overview.stats.revenue"), value: fmtMoney(overview.value?.stats?.revenue) },
22
+ ]);
23
+
24
+ // The role legend mirrors src/data/roles.js so labels always match the bundle.
25
+ const roleBreakers = computed(() => {
26
+ const counts = overview.value?.roles || {};
27
+ return ROLES.map((role) => ({
28
+ ...role,
29
+ count: counts[role.key] || 0,
30
+ })).filter((role) => counts[role.key] != null || role.count > 0);
31
+ });
32
+
33
+ const recentUsers = computed(() => overview.value?.recentUsers || []);
34
+ const entities = computed(() => overview.value?.entities || []);
35
+ const settings = computed(() => overview.value?.settings || {});
36
+ const settingsBadge = computed(() => {
37
+ if (settings.value.maintenance_mode) return t("dashboard.overview.maintenance");
38
+ if (settings.value.registration_open) return t("dashboard.overview.open");
39
+ return t("dashboard.overview.closed");
40
+ });
41
+ const settingsBadgeTint = computed(() =>
42
+ settings.value.maintenance_mode
43
+ ? "bg-amber-500/15 text-amber-300 ring-amber-400/20"
44
+ : settings.value.registration_open
45
+ ? "bg-emerald-500/15 text-emerald-300 ring-emerald-400/20"
46
+ : "bg-rose-500/15 text-rose-300 ring-rose-400/20"
47
+ );
48
+
49
+ function fmtMoney(value) {
50
+ const n = Number(value);
51
+ if (!Number.isFinite(n)) return "—";
52
+ return new Intl.NumberFormat(undefined, { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(n);
53
+ }
54
+
55
+ onMounted(async () => {
56
+ loading.value = true;
57
+ error.value = "";
58
+ try {
59
+ overview.value = await admin.overview();
60
+ } catch (e) {
61
+ error.value = t("dashboard.error");
62
+ } finally {
63
+ loading.value = false;
64
+ }
65
+ });
66
+ </script>
67
+
68
+ <template>
69
+ <div>
70
+ <p v-if="error" class="rounded-xl border border-rose-400/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-300">
71
+ {{ error }}
72
+ </p>
73
+
74
+ <div v-if="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
75
+ <GlassCard v-for="i in 4" :key="i" class="h-24 p-5">
76
+ <div class="h-3 w-1/2 animate-pulse rounded bg-glass3"></div>
77
+ <div class="mt-4 h-8 w-3/4 animate-pulse rounded bg-glass3"></div>
78
+ </GlassCard>
79
+ </div>
80
+
81
+ <template v-else-if="overview">
82
+ <!-- KPI cards -->
83
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
84
+ <GlassCard v-for="s in stats" :key="s.label" hover class="p-5">
85
+ <p class="text-xs font-medium uppercase tracking-wide text-mute">{{ s.label }}</p>
86
+ <p class="mt-2 text-2xl font-extrabold text-gradient">{{ s.value }}</p>
87
+ </GlassCard>
88
+ </div>
89
+
90
+ <div class="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
91
+ <!-- Roles -->
92
+ <GlassCard class="p-5">
93
+ <h2 class="flex items-center gap-2 text-sm font-bold text-ink">
94
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-teal-300">
95
+ <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
96
+ <circle cx="9" cy="7" r="4" />
97
+ <path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
98
+ </svg>
99
+ {{ t("dashboard.overview.roles") }}
100
+ </h2>
101
+ <div class="mt-4 space-y-3">
102
+ <div v-for="role in roleBreakers" :key="role.key" class="flex items-center justify-between gap-3">
103
+ <span class="inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-semibold ring-1" :class="role.badge">
104
+ {{ t(role.label) }}
105
+ </span>
106
+ <span class="text-sm font-bold text-ink">{{ role.count }}</span>
107
+ </div>
108
+ </div>
109
+ </GlassCard>
110
+
111
+ <!-- Recent users -->
112
+ <GlassCard class="p-5">
113
+ <h2 class="flex items-center gap-2 text-sm font-bold text-ink">
114
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-sky-300">
115
+ <circle cx="12" cy="8" r="4" />
116
+ <path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1" />
117
+ </svg>
118
+ {{ t("dashboard.overview.recentUsers") }}
119
+ </h2>
120
+ <ul class="mt-4 space-y-2.5">
121
+ <li v-for="u in recentUsers" :key="u.id" class="flex items-center justify-between gap-3">
122
+ <div class="flex min-w-0 items-center gap-3">
123
+ <span
124
+ class="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-gradient-to-br from-emerald-400/20 to-teal-500/20 text-xs font-bold text-emerald-200 ring-1 ring-edge"
125
+ >
126
+ {{ (u.name || u.email || "?").charAt(0).toUpperCase() }}
127
+ </span>
128
+ <div class="min-w-0">
129
+ <p class="truncate text-sm font-medium text-ink">{{ u.name || "—" }}</p>
130
+ <p class="truncate text-xs text-mute">{{ u.email }}</p>
131
+ </div>
132
+ </div>
133
+ <span
134
+ v-if="u.role"
135
+ class="shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1"
136
+ :class="ROLES.find((r) => r.key === u.role)?.badge || 'bg-glass text-mute ring-edge2'"
137
+ >
138
+ {{ t(`roles.${u.role}.title`) }}
139
+ </span>
140
+ </li>
141
+ </ul>
142
+ </GlassCard>
143
+ </div>
144
+
145
+ <div class="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
146
+ <!-- Entities -->
147
+ <GlassCard class="p-5">
148
+ <h2 class="flex items-center gap-2 text-sm font-bold text-ink">
149
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-cyan-300">
150
+ <rect x="3" y="4" width="18" height="16" rx="2" />
151
+ <path d="M3 9h18M8 4v5M16 4v5" />
152
+ </svg>
153
+ {{ t("dashboard.overview.entities") }}
154
+ </h2>
155
+ <ul class="mt-4 space-y-2.5">
156
+ <li v-for="e in entities" :key="e.name" class="flex items-center justify-between gap-3">
157
+ <span class="rounded-lg border border-edge bg-glass px-2.5 py-1 font-mono text-xs font-semibold text-teal-200">
158
+ {{ e.name }}
159
+ </span>
160
+ <span class="text-sm font-bold text-ink">{{ e.count }}</span>
161
+ </li>
162
+ </ul>
163
+ </GlassCard>
164
+
165
+ <!-- App settings summary -->
166
+ <GlassCard class="p-5">
167
+ <h2 class="flex items-center gap-2 text-sm font-bold text-ink">
168
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-amber-300">
169
+ <circle cx="12" cy="12" r="3" />
170
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
171
+ </svg>
172
+ {{ t("dashboard.overview.settings") }}
173
+ </h2>
174
+ <div class="mt-4 flex flex-wrap items-center gap-3">
175
+ <span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ring-1" :class="settingsBadgeTint">
176
+ {{ settingsBadge }}
177
+ </span>
178
+ <span v-if="settings.app_name" class="rounded-lg border border-edge bg-glass px-2.5 py-1 font-mono text-xs font-semibold text-mute">
179
+ {{ settings.app_name }}
180
+ </span>
181
+ </div>
182
+ </GlassCard>
183
+ </div>
184
+ </template>
185
+ </div>
186
+ </template>