mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 12:43:18 +00:00
Pre-computed merge for change #10
This commit is contained in:
commit
bfb5c52e50
5 changed files with 681 additions and 42 deletions
|
|
@ -17,19 +17,18 @@
|
|||
|
||||
<div v-else-if="detail.kind === 'iam-role' && detail.iamRole" class="iam-detail">
|
||||
<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>
|
||||
<div v-else class="perm-grid mt-3">
|
||||
<div v-for="perm in groupedPermissions" :key="perm.code" class="perm-item">
|
||||
<Checkbox
|
||||
<template v-else>
|
||||
<p class="matrix-intro">
|
||||
Marque columnas <strong>Ver / Crear / Editar / Eliminar</strong> por módulo. Pase el cursor sobre cada casilla para ver qué habilita.
|
||||
</p>
|
||||
<IamPermissionMatrix
|
||||
v-model="localPerms"
|
||||
:input-id="perm.code"
|
||||
:value="perm.code"
|
||||
:catalog="assignableCatalog"
|
||||
:templates="roleTemplates"
|
||||
:disabled="!canEdit"
|
||||
/>
|
||||
<label :for="perm.code">{{ perm.label }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="canEdit"
|
||||
label="Guardar permisos"
|
||||
|
|
@ -38,13 +37,17 @@
|
|||
size="small"
|
||||
@click="savePerms"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { displayErrorMessage } from "~/composables/api-response";
|
||||
|
||||
type PermRow = { code: string; label: string; module?: string; group?: string };
|
||||
import {
|
||||
filterAssignableCatalog,
|
||||
IAM_ROLE_TEMPLATES,
|
||||
type IamPermRow,
|
||||
} from "~/composables/iamPermissionMatrix";
|
||||
|
||||
const props = defineProps<{
|
||||
detail: {
|
||||
|
|
@ -57,18 +60,32 @@ const props = defineProps<{
|
|||
const { api } = useApi();
|
||||
const toast = useToast();
|
||||
const perms = usePermissions();
|
||||
const catalog = ref<PermRow[]>([]);
|
||||
const catalog = ref<IamPermRow[]>([]);
|
||||
const localPerms = ref<string[]>([]);
|
||||
|
||||
const canEdit = computed(() =>
|
||||
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() {
|
||||
const r = await api<{ permissions?: PermRow[] }>("/v1/iam/permissions");
|
||||
catalog.value = r.permissions ?? [];
|
||||
const r = await api<{ permissions?: IamPermRow[] }>("/v1/iam/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) {
|
||||
|
|
@ -103,8 +120,13 @@ watch(
|
|||
<style scoped>
|
||||
.iam-detail { font-size: 13px; }
|
||||
.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); }
|
||||
.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>
|
||||
|
|
|
|||
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>
|
||||
<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>
|
||||
<slot />
|
||||
<small v-if="error" class="p-error">{{ error }}</small>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 align-items-center gap-2">
|
||||
<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>
|
||||
<MultiSelect
|
||||
v-if="!userForm.scope_all_projects"
|
||||
|
|
@ -90,11 +90,11 @@
|
|||
<div class="flex flex-column gap-2">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<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 class="flex align-items-center gap-2">
|
||||
<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>
|
||||
</FormField>
|
||||
|
|
@ -113,11 +113,8 @@
|
|||
<FormField label="Plantilla">
|
||||
<Select v-model="roleForm.template" :options="templateOptions" option-label="label" option-value="value" class="w-full" />
|
||||
</FormField>
|
||||
<FormField label="Código">
|
||||
<InputText v-model="roleForm.code" class="w-full" />
|
||||
</FormField>
|
||||
<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>
|
||||
</div>
|
||||
<template #footer>
|
||||
|
|
@ -184,10 +181,11 @@ const userForm = reactive({
|
|||
|
||||
const roleForm = reactive({
|
||||
template: "operativo",
|
||||
code: "",
|
||||
label: "",
|
||||
});
|
||||
|
||||
const RESERVED_ROLE_CODES = new Set(["tenant_admin", "platform_admin", "user", "operativo"]);
|
||||
|
||||
const templateOptions = [
|
||||
{ label: "Administrador operativo", value: "admin_operativo" },
|
||||
{ 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() {
|
||||
roleForm.template = "operativo";
|
||||
roleForm.code = "";
|
||||
roleForm.label = "";
|
||||
roleForm.label = templateOptions.find((o) => o.value === "operativo")?.label ?? "";
|
||||
showRoleDialog.value = true;
|
||||
}
|
||||
|
||||
async function saveRole() {
|
||||
saving.value = true;
|
||||
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", {
|
||||
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 });
|
||||
showRoleDialog.value = false;
|
||||
|
|
@ -510,6 +542,14 @@ function 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 () => {
|
||||
if (!treeKey.value) treeKey.value = "users-active";
|
||||
await load();
|
||||
|
|
@ -518,4 +558,9 @@ onMounted(async () => {
|
|||
|
||||
<style scoped>
|
||||
.form-grid { display: flex; flex-direction: column; gap: 12px; }
|
||||
.checkbox-caption {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue