mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 17:13:18 +00:00
Compare commits
5 commits
c5f9ac0563
...
9ea4706954
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ea4706954 | ||
|
|
1a50823368 | ||
|
|
2f7f121121 | ||
|
|
d9aeee1073 | ||
|
|
4767676976 |
5 changed files with 681 additions and 42 deletions
|
|
@ -17,34 +17,37 @@
|
||||||
|
|
||||||
<div v-else-if="detail.kind === 'iam-role' && detail.iamRole" class="iam-detail">
|
<div v-else-if="detail.kind === 'iam-role' && detail.iamRole" class="iam-detail">
|
||||||
<div class="section-title">Matriz del rol</div>
|
<div class="section-title">Matriz del rol</div>
|
||||||
<p><strong>{{ detail.iamRole.label }}</strong> ({{ detail.iamRole.code }})</p>
|
<p class="role-heading"><strong>{{ detail.iamRole.label }}</strong> ({{ detail.iamRole.code }})</p>
|
||||||
<Message v-if="detail.iamRole.is_system" severity="info" :closable="false">Rol de sistema: no editable</Message>
|
<Message v-if="detail.iamRole.is_system" severity="info" :closable="false">Rol de sistema: no editable</Message>
|
||||||
<div v-else class="perm-grid mt-3">
|
<template v-else>
|
||||||
<div v-for="perm in groupedPermissions" :key="perm.code" class="perm-item">
|
<p class="matrix-intro">
|
||||||
<Checkbox
|
Marque columnas <strong>Ver / Crear / Editar / Eliminar</strong> por módulo. Pase el cursor sobre cada casilla para ver qué habilita.
|
||||||
v-model="localPerms"
|
</p>
|
||||||
:input-id="perm.code"
|
<IamPermissionMatrix
|
||||||
:value="perm.code"
|
v-model="localPerms"
|
||||||
:disabled="!canEdit"
|
:catalog="assignableCatalog"
|
||||||
/>
|
:templates="roleTemplates"
|
||||||
<label :for="perm.code">{{ perm.label }}</label>
|
:disabled="!canEdit"
|
||||||
</div>
|
/>
|
||||||
</div>
|
<Button
|
||||||
<Button
|
v-if="canEdit"
|
||||||
v-if="canEdit"
|
label="Guardar permisos"
|
||||||
label="Guardar permisos"
|
icon="pi pi-save"
|
||||||
icon="pi pi-save"
|
class="mt-3 w-full"
|
||||||
class="mt-3 w-full"
|
size="small"
|
||||||
size="small"
|
@click="savePerms"
|
||||||
@click="savePerms"
|
/>
|
||||||
/>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { displayErrorMessage } from "~/composables/api-response";
|
import { displayErrorMessage } from "~/composables/api-response";
|
||||||
|
import {
|
||||||
type PermRow = { code: string; label: string; module?: string; group?: string };
|
filterAssignableCatalog,
|
||||||
|
IAM_ROLE_TEMPLATES,
|
||||||
|
type IamPermRow,
|
||||||
|
} from "~/composables/iamPermissionMatrix";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
detail: {
|
detail: {
|
||||||
|
|
@ -57,18 +60,32 @@ const props = defineProps<{
|
||||||
const { api } = useApi();
|
const { api } = useApi();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const perms = usePermissions();
|
const perms = usePermissions();
|
||||||
const catalog = ref<PermRow[]>([]);
|
const catalog = ref<IamPermRow[]>([]);
|
||||||
const localPerms = ref<string[]>([]);
|
const localPerms = ref<string[]>([]);
|
||||||
|
|
||||||
const canEdit = computed(() =>
|
const canEdit = computed(() =>
|
||||||
perms.can("users.update") && props.detail.kind === "iam-role" && !props.detail.iamRole?.is_system,
|
perms.can("users.update") && props.detail.kind === "iam-role" && !props.detail.iamRole?.is_system,
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupedPermissions = computed(() => catalog.value);
|
const assignableCatalog = computed(() => filterAssignableCatalog(catalog.value, perms.isOwner.value));
|
||||||
|
|
||||||
|
const roleTemplates = computed(() => {
|
||||||
|
if (!perms.isOwner.value) {
|
||||||
|
const { admin_operativo: _a, ...rest } = IAM_ROLE_TEMPLATES;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return IAM_ROLE_TEMPLATES;
|
||||||
|
});
|
||||||
|
|
||||||
async function loadCatalog() {
|
async function loadCatalog() {
|
||||||
const r = await api<{ permissions?: PermRow[] }>("/v1/iam/permissions");
|
const r = await api<{ permissions?: IamPermRow[] }>("/v1/iam/permissions");
|
||||||
catalog.value = r.permissions ?? [];
|
catalog.value = (r.permissions ?? []).map((p) => ({
|
||||||
|
code: p.code,
|
||||||
|
label: p.label,
|
||||||
|
module: p.module,
|
||||||
|
verb: p.verb,
|
||||||
|
group: (p as { group?: string }).group,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRolePerms(roleId: number) {
|
async function loadRolePerms(roleId: number) {
|
||||||
|
|
@ -103,8 +120,13 @@ watch(
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.iam-detail { font-size: 13px; }
|
.iam-detail { font-size: 13px; }
|
||||||
.section-title { font-weight: 600; margin-bottom: 4px; }
|
.section-title { font-weight: 600; margin-bottom: 4px; }
|
||||||
|
.role-heading { margin: 0 0 8px; }
|
||||||
|
.matrix-intro {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
.muted { color: var(--text-color-secondary); }
|
.muted { color: var(--text-color-secondary); }
|
||||||
.owner-badge { color: var(--orange-500); font-weight: 600; }
|
.owner-badge { color: var(--orange-500); font-weight: 600; }
|
||||||
.perm-grid { display: grid; grid-template-columns: 1fr; gap: 6px; max-height: 50vh; overflow: auto; }
|
|
||||||
.perm-item { display: flex; align-items: flex-start; gap: 8px; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
277
web-panel/components/IamPermissionMatrix.vue
Normal file
277
web-panel/components/IamPermissionMatrix.vue
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
<template>
|
||||||
|
<div class="iam-perm-matrix">
|
||||||
|
<div class="matrix-toolbar">
|
||||||
|
<IconField class="matrix-search">
|
||||||
|
<InputIcon class="pi pi-search" />
|
||||||
|
<InputText v-model="search" placeholder="Buscar módulo o permiso" size="small" class="w-full" />
|
||||||
|
</IconField>
|
||||||
|
<div class="matrix-quick">
|
||||||
|
<Button label="Solo ver" size="small" text :disabled="disabled" @click="applyViewOnly" />
|
||||||
|
<Button label="Limpiar" size="small" text severity="secondary" :disabled="disabled" @click="clearAll" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="templateOptions.length" class="matrix-templates">
|
||||||
|
<span class="templates-label">Plantilla rápida</span>
|
||||||
|
<Select
|
||||||
|
v-model="pickedTemplate"
|
||||||
|
:options="templateOptions"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
placeholder="Aplicar conjunto…"
|
||||||
|
size="small"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="disabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="matrix-scroll">
|
||||||
|
<section
|
||||||
|
v-for="mod in filteredModules"
|
||||||
|
:key="mod.key"
|
||||||
|
class="matrix-module"
|
||||||
|
>
|
||||||
|
<div class="module-head">
|
||||||
|
<div class="module-title-row">
|
||||||
|
<Checkbox
|
||||||
|
:model-value="moduleChecked(mod) === 'all'"
|
||||||
|
:indeterminate="moduleChecked(mod) === 'partial'"
|
||||||
|
binary
|
||||||
|
:disabled="disabled"
|
||||||
|
@update:model-value="(v: boolean) => toggleModule(mod, v)"
|
||||||
|
/>
|
||||||
|
<span class="module-title">{{ mod.label }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="module-desc">{{ mod.description }}</p>
|
||||||
|
<div class="module-actions">
|
||||||
|
<Button label="Todo" size="small" text :disabled="disabled" @click="toggleModule(mod, true)" />
|
||||||
|
<Button label="Solo ver" size="small" text :disabled="disabled" @click="toggleModuleViewOnly(mod)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="matrix-table-wrap">
|
||||||
|
<table class="matrix-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-area">Área</th>
|
||||||
|
<th v-for="verb in mod.verbs" :key="verb" class="col-verb" :title="IAM_VERB_HINTS[verb]">
|
||||||
|
{{ IAM_VERB_LABELS[verb] }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in mod.rows" :key="row.key">
|
||||||
|
<td class="col-area">
|
||||||
|
<span class="row-label">{{ row.label }}</span>
|
||||||
|
<span v-if="row.hint && mod.rows.length > 1" class="row-hint">{{ row.hint }}</span>
|
||||||
|
</td>
|
||||||
|
<td v-for="verb in mod.verbs" :key="verb" class="col-verb">
|
||||||
|
<Checkbox
|
||||||
|
v-if="row.cells[verb]"
|
||||||
|
v-model="model"
|
||||||
|
:value="row.cells[verb]!.code"
|
||||||
|
:disabled="disabled"
|
||||||
|
v-tooltip.top="row.cells[verb]!.hint"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p v-if="!filteredModules.length" class="matrix-empty">Sin permisos que coincidan con la búsqueda.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
buildIamMatrixModules,
|
||||||
|
IAM_VERB_HINTS,
|
||||||
|
IAM_VERB_LABELS,
|
||||||
|
moduleSelectionState,
|
||||||
|
toggleCodes,
|
||||||
|
viewOnlyCodes,
|
||||||
|
type IamMatrixModule,
|
||||||
|
type IamPermRow,
|
||||||
|
} from "~/composables/iamPermissionMatrix";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
catalog: IamPermRow[];
|
||||||
|
disabled?: boolean;
|
||||||
|
templates?: Record<string, { label: string; permissions: string[] }>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const model = defineModel<string[]>({ required: true });
|
||||||
|
|
||||||
|
const search = ref("");
|
||||||
|
const pickedTemplate = ref<string | null>(null);
|
||||||
|
|
||||||
|
const modules = computed(() => buildIamMatrixModules(props.catalog));
|
||||||
|
|
||||||
|
const templateOptions = computed(() =>
|
||||||
|
Object.entries(props.templates ?? {}).map(([value, t]) => ({ value, label: t.label })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredModules = computed(() => {
|
||||||
|
const q = search.value.trim().toLowerCase();
|
||||||
|
if (!q) return modules.value;
|
||||||
|
return modules.value
|
||||||
|
.map((mod) => {
|
||||||
|
const haystack = [
|
||||||
|
mod.label,
|
||||||
|
mod.description,
|
||||||
|
...mod.rows.flatMap((r) => [r.label, r.hint ?? "", ...Object.values(r.cells).map((c) => c?.label ?? "")]),
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
if (haystack.includes(q)) return mod;
|
||||||
|
const rows = mod.rows.filter((row) => {
|
||||||
|
const rowText = [row.label, row.hint ?? "", ...Object.values(row.cells).map((c) => c?.label ?? "")].join(" ").toLowerCase();
|
||||||
|
return rowText.includes(q);
|
||||||
|
});
|
||||||
|
if (!rows.length) return null;
|
||||||
|
return { ...mod, rows };
|
||||||
|
})
|
||||||
|
.filter(Boolean) as IamMatrixModule[];
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedSet = computed(() => new Set(model.value));
|
||||||
|
|
||||||
|
function moduleChecked(mod: IamMatrixModule): "none" | "partial" | "all" {
|
||||||
|
return moduleSelectionState(selectedSet.value, mod.codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleModule(mod: IamMatrixModule, on: boolean) {
|
||||||
|
model.value = toggleCodes(model.value, mod.codes, on);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleModuleViewOnly(mod: IamMatrixModule) {
|
||||||
|
model.value = toggleCodes(
|
||||||
|
toggleCodes(model.value, mod.codes, false),
|
||||||
|
viewOnlyCodes(mod.codes, props.catalog),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyViewOnly() {
|
||||||
|
const allCodes = modules.value.flatMap((m) => m.codes);
|
||||||
|
model.value = viewOnlyCodes(allCodes, props.catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
model.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTemplate(key: string | null) {
|
||||||
|
if (!key || !props.templates?.[key]) return;
|
||||||
|
const allowed = new Set(props.catalog.map((p) => p.code));
|
||||||
|
model.value = props.templates[key].permissions.filter((c) => allowed.has(c));
|
||||||
|
pickedTemplate.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(pickedTemplate, (key) => {
|
||||||
|
if (key) applyTemplate(key);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.iam-perm-matrix {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.matrix-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.matrix-search { width: 100%; }
|
||||||
|
.matrix-quick {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.matrix-templates {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.templates-label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
.matrix-scroll {
|
||||||
|
max-height: 52vh;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
.matrix-module {
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-card);
|
||||||
|
}
|
||||||
|
.module-head {
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--surface-50);
|
||||||
|
border-bottom: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
.module-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.module-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.module-desc {
|
||||||
|
margin: 4px 0 6px 28px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
.module-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: 28px;
|
||||||
|
}
|
||||||
|
.matrix-table-wrap { overflow-x: auto; }
|
||||||
|
.matrix-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.matrix-table th,
|
||||||
|
.matrix-table td {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--surface-border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.matrix-table th {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
background: var(--surface-ground);
|
||||||
|
}
|
||||||
|
.col-area { min-width: 7rem; text-align: left; }
|
||||||
|
.col-verb { width: 2.5rem; text-align: center; }
|
||||||
|
.row-label { display: block; font-weight: 500; }
|
||||||
|
.row-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.matrix-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<label class="form-field">
|
<!-- div, not label: a wrapping <label> breaks multi-control fields (e.g. checkbox + MultiSelect). -->
|
||||||
|
<div class="form-field">
|
||||||
<span class="form-field-label">{{ label }}<span v-if="required" class="required-mark"> *</span></span>
|
<span class="form-field-label">{{ label }}<span v-if="required" class="required-mark"> *</span></span>
|
||||||
<slot />
|
<slot />
|
||||||
<small v-if="error" class="p-error">{{ error }}</small>
|
<small v-if="error" class="p-error">{{ error }}</small>
|
||||||
</label>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
|
||||||
294
web-panel/composables/iamPermissionMatrix.ts
Normal file
294
web-panel/composables/iamPermissionMatrix.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
export type IamPermRow = {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
module?: string;
|
||||||
|
verb?: string;
|
||||||
|
group?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IamVerb = "view" | "create" | "update" | "delete";
|
||||||
|
|
||||||
|
export type IamMatrixCell = {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IamMatrixRow = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
cells: Partial<Record<IamVerb, IamMatrixCell>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IamMatrixModule = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
rows: IamMatrixRow[];
|
||||||
|
codes: string[];
|
||||||
|
verbs: IamVerb[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const VERB_ORDER: IamVerb[] = ["view", "create", "update", "delete"];
|
||||||
|
|
||||||
|
export const IAM_VERB_LABELS: Record<IamVerb, string> = {
|
||||||
|
view: "Ver",
|
||||||
|
create: "Crear",
|
||||||
|
update: "Editar",
|
||||||
|
delete: "Eliminar",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IAM_VERB_HINTS: Record<IamVerb, string> = {
|
||||||
|
view: "Consultar y abrir pantallas",
|
||||||
|
create: "Registrar datos nuevos",
|
||||||
|
update: "Modificar lo existente",
|
||||||
|
delete: "Eliminar o dar de baja",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODULE_META: Record<string, { label: string; description: string }> = {
|
||||||
|
workers: {
|
||||||
|
label: "Personal",
|
||||||
|
description: "Padrón de trabajadores, expediente y tablero kanban de altas.",
|
||||||
|
},
|
||||||
|
kanban: {
|
||||||
|
label: "Kanban",
|
||||||
|
description: "Tablero visual de seguimiento de personal.",
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
label: "Obras",
|
||||||
|
description: "Alta, consulta y edición de proyectos/obras del tenant.",
|
||||||
|
},
|
||||||
|
companies: {
|
||||||
|
label: "Empresas",
|
||||||
|
description: "Empresas contratistas y subcontratos asociados a obras.",
|
||||||
|
},
|
||||||
|
budget: {
|
||||||
|
label: "Presupuesto",
|
||||||
|
description: "Partidas, importes y estructura económica de cada obra.",
|
||||||
|
},
|
||||||
|
work_program: {
|
||||||
|
label: "Programa de obra",
|
||||||
|
description: "Cronograma, avance físico e importación del programa.",
|
||||||
|
},
|
||||||
|
cost_control: {
|
||||||
|
label: "Control de costos",
|
||||||
|
description: "Comparativo programado vs real y tableros de costo.",
|
||||||
|
},
|
||||||
|
expenses: {
|
||||||
|
label: "Gastos",
|
||||||
|
description: "Captura y seguimiento de egresos por obra.",
|
||||||
|
},
|
||||||
|
warehouse: {
|
||||||
|
label: "Almacén",
|
||||||
|
description: "Entradas, salidas, stock y transferencias de materiales.",
|
||||||
|
},
|
||||||
|
payroll: {
|
||||||
|
label: "Nómina",
|
||||||
|
description: "Periodos, pagos, préstamos y líneas de nómina.",
|
||||||
|
},
|
||||||
|
documents: {
|
||||||
|
label: "Documentos y gafetes",
|
||||||
|
description: "Expediente documental de trabajadores y credenciales.",
|
||||||
|
},
|
||||||
|
project_docs: {
|
||||||
|
label: "Documentos de obra",
|
||||||
|
description: "Expediente por categoría: planos, contratos, permisos, etc.",
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
label: "Usuarios IAM",
|
||||||
|
description: "Administración de cuentas, roles y accesos del tenant.",
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
label: "Configuración",
|
||||||
|
description: "Parámetros generales del tenant (solo propietario).",
|
||||||
|
},
|
||||||
|
reports: {
|
||||||
|
label: "Reportes e inicio",
|
||||||
|
description: "Tablero inicial y reportes operativos.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROJECT_DOC_GROUP_LABELS: Record<string, string> = {
|
||||||
|
tecnico: "Planos y técnicos",
|
||||||
|
contrato: "Contrato",
|
||||||
|
permisos: "Permisos",
|
||||||
|
ambiental: "Ambiental",
|
||||||
|
imss: "IMSS",
|
||||||
|
sst: "SST",
|
||||||
|
otro: "Otros",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODULE_ORDER = [
|
||||||
|
"reports",
|
||||||
|
"projects",
|
||||||
|
"work_program",
|
||||||
|
"budget",
|
||||||
|
"cost_control",
|
||||||
|
"expenses",
|
||||||
|
"workers",
|
||||||
|
"kanban",
|
||||||
|
"documents",
|
||||||
|
"project_docs",
|
||||||
|
"warehouse",
|
||||||
|
"payroll",
|
||||||
|
"companies",
|
||||||
|
"users",
|
||||||
|
"settings",
|
||||||
|
];
|
||||||
|
|
||||||
|
function verbHint(module: string, verb: IamVerb, label: string): string {
|
||||||
|
if (module === "project_docs" && verb === "create") return "Subir archivos en esta categoría";
|
||||||
|
if (module === "warehouse" && verb === "update") return "Salidas, transferencias y cierres";
|
||||||
|
if (module === "payroll" && verb === "update") return "Armar periodo y registrar pagos";
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildIamMatrixModules(catalog: IamPermRow[]): IamMatrixModule[] {
|
||||||
|
const byModule = new Map<string, IamPermRow[]>();
|
||||||
|
for (const p of catalog) {
|
||||||
|
if (!p.module || !p.verb) continue;
|
||||||
|
const list = byModule.get(p.module) ?? [];
|
||||||
|
list.push(p);
|
||||||
|
byModule.set(p.module, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modules: IamMatrixModule[] = [];
|
||||||
|
|
||||||
|
for (const moduleKey of MODULE_ORDER) {
|
||||||
|
const perms = byModule.get(moduleKey);
|
||||||
|
if (!perms?.length) continue;
|
||||||
|
|
||||||
|
const meta = MODULE_META[moduleKey] ?? {
|
||||||
|
label: moduleKey.replace(/_/g, " "),
|
||||||
|
description: "Permisos del módulo.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const verbs = VERB_ORDER.filter((v) => perms.some((p) => p.verb === v));
|
||||||
|
const rows: IamMatrixRow[] = [];
|
||||||
|
|
||||||
|
if (moduleKey === "project_docs") {
|
||||||
|
const groups = new Map<string, IamPermRow[]>();
|
||||||
|
for (const p of perms) {
|
||||||
|
const g = p.group ?? "otro";
|
||||||
|
const list = groups.get(g) ?? [];
|
||||||
|
list.push(p);
|
||||||
|
groups.set(g, list);
|
||||||
|
}
|
||||||
|
for (const [groupKey, groupPerms] of groups) {
|
||||||
|
const cells: Partial<Record<IamVerb, IamMatrixCell>> = {};
|
||||||
|
for (const p of groupPerms) {
|
||||||
|
const verb = p.verb as IamVerb;
|
||||||
|
cells[verb] = {
|
||||||
|
code: p.code,
|
||||||
|
label: p.label,
|
||||||
|
hint: verbHint(moduleKey, verb, p.label),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
key: groupKey,
|
||||||
|
label: PROJECT_DOC_GROUP_LABELS[groupKey] ?? groupKey,
|
||||||
|
cells,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const cells: Partial<Record<IamVerb, IamMatrixCell>> = {};
|
||||||
|
for (const p of perms) {
|
||||||
|
const verb = p.verb as IamVerb;
|
||||||
|
cells[verb] = {
|
||||||
|
code: p.code,
|
||||||
|
label: p.label,
|
||||||
|
hint: verbHint(moduleKey, verb, p.label),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
rows.push({ key: moduleKey, label: meta.label, hint: meta.description, cells });
|
||||||
|
}
|
||||||
|
|
||||||
|
modules.push({
|
||||||
|
key: moduleKey,
|
||||||
|
label: meta.label,
|
||||||
|
description: meta.description,
|
||||||
|
rows,
|
||||||
|
codes: perms.map((p) => p.code),
|
||||||
|
verbs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return modules;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterAssignableCatalog(catalog: IamPermRow[], isOwner: boolean): IamPermRow[] {
|
||||||
|
if (isOwner) return catalog;
|
||||||
|
return catalog.filter(
|
||||||
|
(p) => !p.code.startsWith("users.") && !p.code.startsWith("settings."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleCodes(selected: string[], codes: string[], on: boolean): string[] {
|
||||||
|
const set = new Set(selected);
|
||||||
|
for (const code of codes) {
|
||||||
|
if (on) set.add(code);
|
||||||
|
else set.delete(code);
|
||||||
|
}
|
||||||
|
return [...set];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleSelectionState(selected: Set<string>, codes: string[]): "none" | "partial" | "all" {
|
||||||
|
const hit = codes.filter((c) => selected.has(c)).length;
|
||||||
|
if (hit === 0) return "none";
|
||||||
|
if (hit === codes.length) return "all";
|
||||||
|
return "partial";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function viewOnlyCodes(codes: string[], catalog: IamPermRow[]): string[] {
|
||||||
|
const viewSet = new Set(catalog.filter((p) => p.verb === "view").map((p) => p.code));
|
||||||
|
return codes.filter((c) => viewSet.has(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conjuntos predefinidos (mismas claves que api/iam_catalog.ts ROLE_TEMPLATES). */
|
||||||
|
export const IAM_ROLE_TEMPLATES: Record<string, { label: string; permissions: string[] }> = {
|
||||||
|
admin_operativo: {
|
||||||
|
label: "Administrador operativo",
|
||||||
|
permissions: [
|
||||||
|
"workers.view", "workers.create", "workers.update", "workers.delete", "kanban.view",
|
||||||
|
"projects.view", "projects.create", "projects.update",
|
||||||
|
"companies.view", "companies.create", "companies.update",
|
||||||
|
"budget.view", "budget.create", "budget.update", "budget.delete",
|
||||||
|
"work_program.view", "work_program.create", "work_program.update",
|
||||||
|
"cost_control.view",
|
||||||
|
"expenses.view", "expenses.create", "expenses.update", "expenses.delete",
|
||||||
|
"warehouse.view", "warehouse.create", "warehouse.update",
|
||||||
|
"payroll.view", "payroll.create", "payroll.update", "payroll.delete",
|
||||||
|
"documents.view", "documents.create",
|
||||||
|
"project_docs.tecnico.view", "project_docs.tecnico.create",
|
||||||
|
"project_docs.contrato.view", "project_docs.contrato.create",
|
||||||
|
"project_docs.permisos.view", "project_docs.permisos.create",
|
||||||
|
"project_docs.ambiental.view", "project_docs.ambiental.create",
|
||||||
|
"project_docs.imss.view", "project_docs.imss.create",
|
||||||
|
"project_docs.sst.view", "project_docs.sst.create",
|
||||||
|
"project_docs.otro.view", "project_docs.otro.create",
|
||||||
|
"reports.view",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
supervisor_obra: {
|
||||||
|
label: "Supervisor de obra",
|
||||||
|
permissions: [
|
||||||
|
"projects.view", "work_program.view", "work_program.update",
|
||||||
|
"project_docs.tecnico.view", "reports.view",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
contador: {
|
||||||
|
label: "Contador",
|
||||||
|
permissions: [
|
||||||
|
"expenses.view", "expenses.create", "expenses.update", "expenses.delete",
|
||||||
|
"cost_control.view", "reports.view", "payroll.view",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
operativo: {
|
||||||
|
label: "Operativo",
|
||||||
|
permissions: [
|
||||||
|
"workers.view", "workers.create", "workers.update",
|
||||||
|
"documents.view", "documents.create", "reports.view", "kanban.view",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -73,7 +73,7 @@
|
||||||
<div class="flex flex-column gap-2">
|
<div class="flex flex-column gap-2">
|
||||||
<div class="flex align-items-center gap-2">
|
<div class="flex align-items-center gap-2">
|
||||||
<Checkbox v-model="userForm.scope_all_projects" input-id="scope-all" binary />
|
<Checkbox v-model="userForm.scope_all_projects" input-id="scope-all" binary />
|
||||||
<label for="scope-all">Todas las obras</label>
|
<span class="checkbox-caption">Todas las obras</span>
|
||||||
</div>
|
</div>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
v-if="!userForm.scope_all_projects"
|
v-if="!userForm.scope_all_projects"
|
||||||
|
|
@ -90,11 +90,11 @@
|
||||||
<div class="flex flex-column gap-2">
|
<div class="flex flex-column gap-2">
|
||||||
<div class="flex align-items-center gap-2">
|
<div class="flex align-items-center gap-2">
|
||||||
<Checkbox v-model="userForm.warehouse_central" input-id="wh-central" binary />
|
<Checkbox v-model="userForm.warehouse_central" input-id="wh-central" binary />
|
||||||
<label for="wh-central">Almacén central</label>
|
<span class="checkbox-caption">Almacén central</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex align-items-center gap-2">
|
<div class="flex align-items-center gap-2">
|
||||||
<Checkbox v-model="userForm.warehouse_projects" input-id="wh-projects" binary />
|
<Checkbox v-model="userForm.warehouse_projects" input-id="wh-projects" binary />
|
||||||
<label for="wh-projects">Almacenes de obra</label>
|
<span class="checkbox-caption">Almacenes de obra</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
@ -113,11 +113,8 @@
|
||||||
<FormField label="Plantilla">
|
<FormField label="Plantilla">
|
||||||
<Select v-model="roleForm.template" :options="templateOptions" option-label="label" option-value="value" class="w-full" />
|
<Select v-model="roleForm.template" :options="templateOptions" option-label="label" option-value="value" class="w-full" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Código">
|
|
||||||
<InputText v-model="roleForm.code" class="w-full" />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Etiqueta">
|
<FormField label="Etiqueta">
|
||||||
<InputText v-model="roleForm.label" class="w-full" />
|
<InputText v-model="roleForm.label" class="w-full" placeholder="Nombre visible del rol" />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|
@ -184,10 +181,11 @@ const userForm = reactive({
|
||||||
|
|
||||||
const roleForm = reactive({
|
const roleForm = reactive({
|
||||||
template: "operativo",
|
template: "operativo",
|
||||||
code: "",
|
|
||||||
label: "",
|
label: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const RESERVED_ROLE_CODES = new Set(["tenant_admin", "platform_admin", "user", "operativo"]);
|
||||||
|
|
||||||
const templateOptions = [
|
const templateOptions = [
|
||||||
{ label: "Administrador operativo", value: "admin_operativo" },
|
{ label: "Administrador operativo", value: "admin_operativo" },
|
||||||
{ label: "Supervisor de obra", value: "supervisor_obra" },
|
{ label: "Supervisor de obra", value: "supervisor_obra" },
|
||||||
|
|
@ -384,20 +382,54 @@ async function resetAccess() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function slugRoleCode(text: string): string {
|
||||||
|
let s = text
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/\p{M}/gu, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
.replace(/_+/g, "_");
|
||||||
|
if (!s || !/^[a-z]/.test(s)) s = `rol_${s || "nuevo"}`;
|
||||||
|
return s.slice(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueRoleCode(base: string): string {
|
||||||
|
const taken = new Set(roles.value.map((r) => r.code));
|
||||||
|
let code = base;
|
||||||
|
let n = 2;
|
||||||
|
while (taken.has(code) || RESERVED_ROLE_CODES.has(code)) {
|
||||||
|
const suffix = `_${n}`;
|
||||||
|
code = `${base.slice(0, Math.max(1, 64 - suffix.length))}${suffix}`;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleCodeFromForm(): string {
|
||||||
|
const label = roleForm.label.trim();
|
||||||
|
const base = slugRoleCode(label || roleForm.template);
|
||||||
|
return uniqueRoleCode(base);
|
||||||
|
}
|
||||||
|
|
||||||
function openNewRole() {
|
function openNewRole() {
|
||||||
roleForm.template = "operativo";
|
roleForm.template = "operativo";
|
||||||
roleForm.code = "";
|
roleForm.label = templateOptions.find((o) => o.value === "operativo")?.label ?? "";
|
||||||
roleForm.label = "";
|
|
||||||
showRoleDialog.value = true;
|
showRoleDialog.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveRole() {
|
async function saveRole() {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
const code = (roleForm.code || roleForm.template).trim().toLowerCase();
|
const label = roleForm.label.trim();
|
||||||
|
if (!label) {
|
||||||
|
toast.add({ severity: "warn", summary: "Indique la etiqueta del rol", life: 3000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const code = roleCodeFromForm();
|
||||||
await api("/v1/iam/roles", {
|
await api("/v1/iam/roles", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
json: { code, label: roleForm.label || code, template: roleForm.template },
|
json: { code, label, template: roleForm.template },
|
||||||
});
|
});
|
||||||
toast.add({ severity: "success", summary: "Rol creado", life: 2500 });
|
toast.add({ severity: "success", summary: "Rol creado", life: 2500 });
|
||||||
showRoleDialog.value = false;
|
showRoleDialog.value = false;
|
||||||
|
|
@ -510,6 +542,14 @@ function syncChrome() {
|
||||||
|
|
||||||
watch([selectedUser, selectedRole, treeKey], syncChrome);
|
watch([selectedUser, selectedRole, treeKey], syncChrome);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => roleForm.template,
|
||||||
|
(tpl) => {
|
||||||
|
const opt = templateOptions.find((o) => o.value === tpl);
|
||||||
|
if (opt) roleForm.label = opt.label;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!treeKey.value) treeKey.value = "users-active";
|
if (!treeKey.value) treeKey.value = "users-active";
|
||||||
await load();
|
await load();
|
||||||
|
|
@ -518,4 +558,9 @@ onMounted(async () => {
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.form-grid { display: flex; flex-direction: column; gap: 12px; }
|
.form-grid { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.checkbox-caption {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue