Compare commits
3 Commits
d7eb91ee68
...
f945e9a996
| Author | SHA1 | Date | |
|---|---|---|---|
| f945e9a996 | |||
| 454e68d5c8 | |||
| 4c2aa11c03 |
@ -58,3 +58,49 @@
|
||||
--spacing-shell-avatar: 35px;
|
||||
--shadow-shell-popover: 0 3px 12px rgb(12 40 80 / 8%);
|
||||
}
|
||||
|
||||
/* Enlaces de contenido y controles que navegan: criterio común en toda la aplicación. */
|
||||
@layer components {
|
||||
.app-link {
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.app-link:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
.app-link:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.app-link:disabled,
|
||||
.app-link[data-disabled],
|
||||
.app-link[aria-disabled="true"] {
|
||||
cursor: default;
|
||||
text-decoration-line: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Los diálogos necesitan separarse visualmente del fondo y de la capa de
|
||||
superposición sin depender de una variante concreta de formulario. */
|
||||
[data-slot="dialog-content"] {
|
||||
box-shadow:
|
||||
0 18px 45px rgb(12 40 80 / 16%),
|
||||
0 4px 12px rgb(12 40 80 / 10%);
|
||||
}
|
||||
|
||||
/* El diálogo activo necesita un límite inequívoco cuando aparece sobre otro. */
|
||||
[data-slot="dialog-content"] {
|
||||
border: 1px solid color-mix(in oklch, var(--foreground) 1%, var(--border));
|
||||
box-shadow:
|
||||
0 24px 60px rgb(12 40 80 / 24%),
|
||||
0 8px 18px rgb(12 40 80 / 14%),
|
||||
0 0 0 1px rgb(12 40 80 / 8%);
|
||||
}
|
||||
|
||||
[data-slot="dialog-content"] [data-slot="dialog-header"] {
|
||||
background-color: var(--popover);
|
||||
}
|
||||
|
||||
@ -309,3 +309,11 @@ Las secciones de consulta evitan recuadros anidados y rellenos laterales: usan t
|
||||
`DetailSection` ofrece agrupación plegable nativa (`details/summary`), abierta inicialmente, accesible por teclado. `DetailField` devuelve una fila semántica `dt/dd` para incluir en un `dl`: etiqueta y valor se alinean horizontalmente cuando el contenedor dispone de al menos 240 px y se apilan en espacios menores. El consumidor aporta los enlaces; el componente común les aplica cursor de enlace, color semántico y subrayado al pasar el puntero. Los valores ausentes permanecen como texto.
|
||||
|
||||
La ficha lateral de activos utiliza un ancho adaptable entre 320 y 400 px, acciones debajo del título y bloques de identificación, ubicación/asignación, características, observaciones, relaciones y asistencia. Conserva las secciones capturadas de la plantilla, incluyendo la compatibilidad con especificaciones antiguas sin agrupación; no modifica valores ni instantáneas. Las relaciones llevan a destinos existentes: persona y activo relacionado a su ficha; sede, puesto y plantilla a sus listados/catálogos actuales. La vista completa e historial mantienen sus funciones. Las fichas de recursos reutilizan las filas de características. No se crean nuevas fichas laterales ni nuevas rutas.
|
||||
|
||||
## Enlaces y controles de navegación
|
||||
|
||||
Los enlaces de contenido usan `app-link`: color semántico `primary`, cursor de mano, subrayado al pasar el puntero y foco visible por teclado. `BrowserLink` lo incorpora de forma predeterminada, conservando la navegación interna, los clics modificados, destinos externos y descargas. Los botones que presentan nombres navegables o «Ver todos» comparten este estilo sin alterar sus callbacks. Los controles deshabilitados no muestran cursor de enlace ni subrayado. La navegación lateral y los enlaces presentados como botones conservan los colores de su contexto para mantener contraste; las acciones de guardar, editar o cancelar mantienen su presentación de botón.
|
||||
|
||||
En el menú de secciones de una compañía, los elementos utilizan el color normal de texto y solo destacan su texto al pasar el puntero. Se mantienen el cursor de mano, el foco por teclado y el indicador de la sección actual. Esta excepción no afecta a enlaces dentro de listados, fichas o bloques del dashboard.
|
||||
|
||||
El cuerpo desplazable `FormBody` reserva 4 px interiores en ambos laterales y al inicio para el anillo exterior de foco (3 px). No debe eliminarse ese espacio: `overflow-y-auto` también recorta horizontalmente y produciría un foco asimétrico en campos pegados al borde. Se conserva el scroll y las acciones fijas.
|
||||
|
||||
@ -22,7 +22,7 @@ export function AssetRelationships(props: AssetRelationshipsProps) {
|
||||
<p className="font-medium">
|
||||
{source ? link.values.name : link.values.inverseName} ·{" "}
|
||||
<BrowserLink
|
||||
className="cursor-pointer text-primary underline-offset-4 hover:underline"
|
||||
className="cursor-pointer text-primary underline-offset-4 app-link text-primary hover:text-primary"
|
||||
href={`/companies/${companyId}/assets/${source ? link.values.targetId : link.values.sourceId}`}
|
||||
>
|
||||
{nameOf(source ? link.values.targetId : link.values.sourceId)}
|
||||
|
||||
@ -100,7 +100,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
||||
render: (asset) => (
|
||||
<BrowserLink
|
||||
aria-pressed={selected?.id === asset.id && detailOpen}
|
||||
className="cursor-pointer text-left font-medium [overflow-wrap:anywhere] hover:underline focus-visible:rounded-[4px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
|
||||
className="cursor-pointer text-left font-medium [overflow-wrap:anywhere] app-link text-primary hover:text-primary focus-visible:rounded-[4px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
|
||||
href={entityHref(asset.id)}
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
|
||||
@ -21,7 +21,7 @@ export function CatalogName({
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={onView}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@ -68,7 +68,7 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(item.id)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@ -12,4 +12,10 @@ export interface ICompanyRepository {
|
||||
id: UniqueID,
|
||||
change: (company: Company) => Result<Company, CompanyError>
|
||||
): Promise<Result<Company, CompanyError>>;
|
||||
removePerson(id: UniqueID, personId: UniqueID): Promise<Result<Company, CompanyError>>;
|
||||
removeAssignment(
|
||||
id: UniqueID,
|
||||
personId: UniqueID,
|
||||
assignmentId: UniqueID
|
||||
): Promise<Result<Company, CompanyError>>;
|
||||
}
|
||||
|
||||
@ -97,6 +97,14 @@ export class CompanyUseCases {
|
||||
company.updatePerson(child.data, props.data)
|
||||
);
|
||||
}
|
||||
async removePerson(companyId: string, id: string) {
|
||||
const parent = this.input.id(companyId);
|
||||
const child = this.input.id(id);
|
||||
if (!(parent.isSuccess && child.isSuccess)) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
return this.companies.removePerson(parent.data, child.data);
|
||||
}
|
||||
async addDepartment(id: string, dto: DepartmentRequestDTO) {
|
||||
const parsed = this.input.id(id);
|
||||
const props = this.input.department(dto);
|
||||
@ -148,6 +156,15 @@ export class CompanyUseCases {
|
||||
company.updateAssignment(person.data, child.data, values.data, new Date().toISOString())
|
||||
);
|
||||
}
|
||||
async removeAssignment(companyId: string, personId: string, id: string) {
|
||||
const parent = this.input.id(companyId);
|
||||
const person = this.input.id(personId);
|
||||
const assignment = this.input.id(id);
|
||||
if (!(parent.isSuccess && person.isSuccess && assignment.isSuccess)) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
return this.companies.removeAssignment(parent.data, person.data, assignment.data);
|
||||
}
|
||||
async addSite(id: string, dto: SiteRequestDTO) {
|
||||
const parsed = this.input.id(id);
|
||||
const props = this.input.site(dto);
|
||||
|
||||
@ -4,9 +4,11 @@ export type CompanyErrorCode =
|
||||
| "CONTACT_NOT_FOUND"
|
||||
| "SITE_NOT_FOUND"
|
||||
| "PERSON_NOT_FOUND"
|
||||
| "PERSON_IN_USE"
|
||||
| "DEPARTMENT_NOT_FOUND"
|
||||
| "DEPARTMENT_NAME_EXISTS"
|
||||
| "ASSIGNMENT_NOT_FOUND"
|
||||
| "ASSIGNMENT_IN_USE"
|
||||
| "ASSIGNMENT_CONFLICT"
|
||||
| "INACTIVE_ASSIGNMENT_TARGET";
|
||||
export class CompanyError extends Error {
|
||||
|
||||
@ -109,6 +109,20 @@ export class Company extends AggregateRoot<CompanyProps> {
|
||||
)
|
||||
);
|
||||
}
|
||||
removePerson(id: UniqueID): Result<Company, CompanyError> {
|
||||
if (!this.people.some((person) => person.id.equals(id))) {
|
||||
return Result.fail(new CompanyError("PERSON_NOT_FOUND"));
|
||||
}
|
||||
if (this.assignments.some((assignment) => assignment.personId.equals(id))) {
|
||||
return Result.fail(new CompanyError("PERSON_IN_USE"));
|
||||
}
|
||||
return Result.ok(
|
||||
new Company(
|
||||
{ ...this.props, people: this.people.filter((person) => !person.id.equals(id)) },
|
||||
this.id
|
||||
)
|
||||
);
|
||||
}
|
||||
private departmentNameExists(name: string, except?: UniqueID): boolean {
|
||||
const key = name.normalize("NFC").toLowerCase();
|
||||
return this.departments.some(
|
||||
@ -256,6 +270,20 @@ export class Company extends AggregateRoot<CompanyProps> {
|
||||
)
|
||||
);
|
||||
}
|
||||
removeAssignment(personId: UniqueID, id: UniqueID): Result<Company, CompanyError> {
|
||||
const assignment = this.assignments.find(
|
||||
(item) => item.id.equals(id) && item.personId.equals(personId)
|
||||
);
|
||||
if (!assignment) {
|
||||
return Result.fail(new CompanyError("ASSIGNMENT_NOT_FOUND"));
|
||||
}
|
||||
return Result.ok(
|
||||
new Company(
|
||||
{ ...this.props, assignments: this.assignments.filter((item) => !item.id.equals(id)) },
|
||||
this.id
|
||||
)
|
||||
);
|
||||
}
|
||||
addSite(site: Site): Result<Company, CompanyError> {
|
||||
if (!site.companyId.equals(this.id)) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
|
||||
@ -126,6 +126,17 @@ export function registerCompanyRoutes(
|
||||
post("/:companyId/people/:id/update", PersonRequestSchema, ChildParamsSchema, (dto, params) =>
|
||||
useCases.updatePerson(params.companyId, params.id, dto)
|
||||
);
|
||||
router.post("/:companyId/people/:id/delete", (request, response, next) => {
|
||||
const params = ChildParamsSchema.safeParse(request.params);
|
||||
if (!(params.success && z.strictObject({}).safeParse(request.body).success)) {
|
||||
sendError(response, "INVALID_REQUEST", 400);
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.removePerson(params.data.companyId, params.data.id)
|
||||
.then((result) => sendResult(response, result))
|
||||
.catch(next);
|
||||
});
|
||||
post(
|
||||
"/:companyId/departments",
|
||||
DepartmentRequestSchema,
|
||||
@ -146,6 +157,17 @@ export function registerCompanyRoutes(
|
||||
(dto, params) => useCases.addAssignment(params.companyId, params.personId, dto),
|
||||
201
|
||||
);
|
||||
router.post("/:companyId/people/:personId/assignments/:id/delete", (request, response, next) => {
|
||||
const params = AssignmentParamsSchema.safeParse(request.params);
|
||||
if (!(params.success && z.strictObject({}).safeParse(request.body).success)) {
|
||||
sendError(response, "INVALID_REQUEST", 400);
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.removeAssignment(params.data.companyId, params.data.personId, params.data.id)
|
||||
.then((result) => sendResult(response, result))
|
||||
.catch(next);
|
||||
});
|
||||
post(
|
||||
"/:companyId/people/:personId/assignments/:id/update",
|
||||
AssignmentUpdateRequestSchema,
|
||||
|
||||
@ -102,6 +102,55 @@ export class SequelizeCompanyRepository implements ICompanyRepository {
|
||||
await CompanyModel.create(this.mapper.toPersistence(company).company);
|
||||
return company;
|
||||
}
|
||||
async removePerson(
|
||||
id: UniqueID,
|
||||
personId: UniqueID
|
||||
): Promise<DomainResult<Company, CompanyError>> {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const current = (await this.load(id, transaction, true)).getOrUndefined();
|
||||
if (!current) {
|
||||
return Result.fail(new CompanyError("COMPANY_NOT_FOUND"));
|
||||
}
|
||||
const changed = current.removePerson(personId);
|
||||
if (!changed.isSuccess) {
|
||||
return changed;
|
||||
}
|
||||
try {
|
||||
await PersonModel.destroy({
|
||||
where: { id: personId.toPrimitive(), companyId: id.toPrimitive() },
|
||||
transaction,
|
||||
});
|
||||
} catch {
|
||||
return Result.fail(new CompanyError("PERSON_IN_USE"));
|
||||
}
|
||||
return changed;
|
||||
});
|
||||
}
|
||||
async removeAssignment(
|
||||
id: UniqueID,
|
||||
personId: UniqueID,
|
||||
assignmentId: UniqueID
|
||||
): Promise<DomainResult<Company, CompanyError>> {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const current = (await this.load(id, transaction, true)).getOrUndefined();
|
||||
if (!current) {
|
||||
return Result.fail(new CompanyError("COMPANY_NOT_FOUND"));
|
||||
}
|
||||
const changed = current.removeAssignment(personId, assignmentId);
|
||||
if (!changed.isSuccess) {
|
||||
return changed;
|
||||
}
|
||||
await AssignmentRevisionModel.destroy({
|
||||
where: { assignmentId: assignmentId.toPrimitive() },
|
||||
transaction,
|
||||
});
|
||||
await AssignmentModel.destroy({
|
||||
where: { id: assignmentId.toPrimitive(), companyId: id.toPrimitive() },
|
||||
transaction,
|
||||
});
|
||||
return changed;
|
||||
});
|
||||
}
|
||||
async update(
|
||||
id: UniqueID,
|
||||
change: (company: Company) => DomainResult<Company, CompanyError>
|
||||
|
||||
@ -909,7 +909,15 @@
|
||||
"removed": "The logo will be removed on save.",
|
||||
"remove": "Remove logo",
|
||||
"undo": "Undo logo change"
|
||||
}
|
||||
},
|
||||
"deletePerson": "Delete person",
|
||||
"deletingPerson": "Deleting…",
|
||||
"deletePersonConfirm": "Delete {{name}}? It can only be deleted when it has no registered relationships.",
|
||||
"deletePersonError": "This person cannot be deleted because it has registered relationships.",
|
||||
"deleteAssignment": "Delete assignment",
|
||||
"deletingAssignment": "Deleting…",
|
||||
"deleteAssignmentConfirm": "Delete the assignment to {{name}}?",
|
||||
"deleteAssignmentError": "The assignment could not be deleted."
|
||||
},
|
||||
"access": {
|
||||
"helpTitle": "What is it and what is it for?",
|
||||
|
||||
@ -909,7 +909,15 @@
|
||||
"removed": "Se quitará el logotipo al guardar.",
|
||||
"remove": "Quitar logotipo",
|
||||
"undo": "Deshacer cambio de logotipo"
|
||||
}
|
||||
},
|
||||
"deletePerson": "Eliminar persona",
|
||||
"deletingPerson": "Eliminando…",
|
||||
"deletePersonConfirm": "¿Eliminar a {{name}}? Solo se puede eliminar si no tiene relaciones registradas.",
|
||||
"deletePersonError": "No se puede eliminar esta persona porque tiene relaciones registradas.",
|
||||
"deleteAssignment": "Eliminar asignación",
|
||||
"deletingAssignment": "Eliminando…",
|
||||
"deleteAssignmentConfirm": "¿Eliminar la asignación al departamento {{name}}?",
|
||||
"deleteAssignmentError": "No se puede eliminar la asignación."
|
||||
},
|
||||
"access": {
|
||||
"helpTitle": "¿Qué es y para qué sirve?",
|
||||
|
||||
@ -80,7 +80,7 @@ export function ApplicationConfigurationsPage(props: ApplicationConfigurationsPa
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(item.id)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@ -1 +1 @@
|
||||
export { saveAssignment } from "./save-assignment.api";
|
||||
export { deleteAssignment, saveAssignment } from "./save-assignment.api";
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
import { CompanyResponseSchema } from "../../../../common";
|
||||
import { companyRequest } from "../../../companies/shared/api/company-request";
|
||||
|
||||
export const deleteAssignment = (companyId: string, personId: string, id: string, token: string) =>
|
||||
companyRequest(
|
||||
`/${companyId}/people/${personId}/assignments/${id}/delete`,
|
||||
CompanyResponseSchema,
|
||||
{},
|
||||
token
|
||||
);
|
||||
|
||||
export const saveAssignment = (
|
||||
companyId: string,
|
||||
personId: string,
|
||||
|
||||
@ -80,7 +80,7 @@ export function CertificateConfigurationsPage(props: CertificateConfigurationsPa
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(item.id)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@ -158,8 +158,8 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
||||
aria-pressed={tab === section}
|
||||
className={
|
||||
embedded
|
||||
? "h-auto min-h-10 w-full justify-start whitespace-normal text-left text-[13px] leading-[1.4285714286] aria-pressed:bg-accent aria-pressed:hover:bg-accent dark:aria-pressed:hover:bg-accent aria-pressed:text-accent-foreground shell:text-sm"
|
||||
: undefined
|
||||
? "cursor-pointer text-foreground hover:text-primary h-auto min-h-10 w-full justify-start whitespace-normal text-left text-[13px] leading-[1.4285714286] aria-pressed:bg-accent aria-pressed:hover:bg-accent dark:aria-pressed:hover:bg-accent aria-pressed:text-accent-foreground shell:text-sm"
|
||||
: "cursor-pointer text-foreground hover:text-primary"
|
||||
}
|
||||
{...(props.onSectionChange
|
||||
? {
|
||||
|
||||
@ -85,7 +85,7 @@ export function CompaniesPage(props: CompaniesPageProps) {
|
||||
hideable: false,
|
||||
render: (company) => (
|
||||
<BrowserLink
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
href={companySectionHref(company.id, "overview")}
|
||||
>
|
||||
<CompanyIdentity
|
||||
|
||||
@ -9,7 +9,7 @@ export function DashboardNavigationButton({
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
"text-left font-semibold text-primary hover:underline focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-3 focus-visible:outline-ring",
|
||||
"text-left font-semibold app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-3 focus-visible:outline-ring",
|
||||
className
|
||||
)}
|
||||
type="button"
|
||||
|
||||
@ -30,7 +30,12 @@ export function DashboardPanel({
|
||||
{title}
|
||||
</h2>
|
||||
{action && (
|
||||
<Button className="text-primary shrink-0" onClick={action} size="sm" variant="ghost">
|
||||
<Button
|
||||
className="app-link text-primary hover:text-primary shrink-0"
|
||||
onClick={action}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{t("dashboard.viewAll")}
|
||||
<ChevronRightIcon aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
|
||||
@ -73,7 +73,7 @@ export function FolderMappingsPage(props: FolderMappingsPageProps) {
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(item.id)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@ -84,7 +84,7 @@ export function InterventionsPage(props: InterventionsPageProps) {
|
||||
hideable: false,
|
||||
render: (intervention) => (
|
||||
<BrowserLink
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
href={`/companies/${intervention.companyId}/interventions/${intervention.id}`}
|
||||
>
|
||||
{intervention.reference}
|
||||
@ -98,7 +98,7 @@ export function InterventionsPage(props: InterventionsPageProps) {
|
||||
hideable: false,
|
||||
render: (intervention) => (
|
||||
<BrowserLink
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
href={`/companies/${intervention.companyId}/interventions/${intervention.id}`}
|
||||
>
|
||||
{intervention.values.subject}
|
||||
|
||||
@ -79,7 +79,7 @@ export function MailConfigurationsPage(props: MailConfigurationsPageProps) {
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(item.id)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@ -11,6 +11,8 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO, PersonDTO } from "../../../../common";
|
||||
import { AssignmentEditorDialog, AssignmentHistoryDialog } from "../../../assignments";
|
||||
import { deleteAssignment } from "../../../assignments/shared/api";
|
||||
import { deletePerson } from "../../shared/api";
|
||||
|
||||
export function PersonDetails({
|
||||
company,
|
||||
@ -28,6 +30,8 @@ export function PersonDetails({
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [editor, setEditor] = useState<string | undefined>();
|
||||
const [historyId, setHistoryId] = useState<string | undefined>();
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deletingAssignment, setDeletingAssignment] = useState<string | undefined>();
|
||||
const assignments = company.assignments.filter((item) => item.personId === person.id);
|
||||
const selected = assignments.find((item) => item.id === editor);
|
||||
const history = assignments.find((item) => item.id === historyId);
|
||||
@ -46,9 +50,31 @@ export function PersonDetails({
|
||||
{person.isContact ? ` · ${t("isContact")}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onEdit} variant="outline">
|
||||
{t("editPerson")}
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={onEdit} variant="outline">
|
||||
{t("editPerson")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={deleting}
|
||||
onClick={async () => {
|
||||
if (!window.confirm(t("deletePersonConfirm", { name: person.name }))) {
|
||||
return;
|
||||
}
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deletePerson(company.id, person.id, csrfToken);
|
||||
onBack();
|
||||
} catch {
|
||||
window.alert(t("deletePersonError"));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
{t(deleting ? "deletingPerson" : "deletePerson")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="grid gap-4 pt-6 sm:grid-cols-3">
|
||||
@ -132,6 +158,32 @@ export function PersonDetails({
|
||||
<EntityAction onClick={() => setHistoryId(item.id)}>
|
||||
{t("history")}
|
||||
</EntityAction>
|
||||
<EntityAction
|
||||
onClick={async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
t("deleteAssignmentConfirm", {
|
||||
name: departmentName(item.departmentId),
|
||||
})
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setDeletingAssignment(item.id);
|
||||
try {
|
||||
await deleteAssignment(company.id, person.id, item.id, csrfToken);
|
||||
window.location.reload();
|
||||
} catch {
|
||||
window.alert(t("deleteAssignmentError"));
|
||||
} finally {
|
||||
setDeletingAssignment(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
deletingAssignment === item.id ? "deletingAssignment" : "deleteAssignment"
|
||||
)}
|
||||
</EntityAction>
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
|
||||
@ -104,7 +104,7 @@ export function PeopleSection({
|
||||
render: (person) => (
|
||||
<>
|
||||
<BrowserLink
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
href={entityHref(person.id)}
|
||||
>
|
||||
{person.name}
|
||||
|
||||
@ -1 +1 @@
|
||||
export { savePerson } from "./save-person.api";
|
||||
export { deletePerson, savePerson } from "./save-person.api";
|
||||
|
||||
@ -13,3 +13,6 @@ export const savePerson = (
|
||||
body,
|
||||
token
|
||||
);
|
||||
|
||||
export const deletePerson = (companyId: string, id: string, token: string) =>
|
||||
companyRequest(`/${companyId}/people/${id}/delete`, CompanyResponseSchema, {}, token);
|
||||
|
||||
@ -74,7 +74,7 @@ export function PrinterConfigurationsPage(props: PrinterConfigurationsPageProps)
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(item.id)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@ -96,7 +96,7 @@ export function ProcessesPage(props: ProcessesPageProps) {
|
||||
hideable: false,
|
||||
render: (p) => (
|
||||
<BrowserLink
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
href={`/companies/${company.id}/processes/${p.id}`}
|
||||
>
|
||||
{p.values.name}
|
||||
|
||||
@ -77,7 +77,7 @@ export function RemoteConnectionsPage(props: RemoteConnectionsPageProps) {
|
||||
hideable: false,
|
||||
render: (c) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(c.id)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@ -77,7 +77,7 @@ export function TeleworkAuthorizationsPage(props: TeleworkAuthorizationsPageProp
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(item.id)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@ -87,7 +87,7 @@ export function TicketsPage(props: TicketsPageProps) {
|
||||
hideable: false,
|
||||
render: (ticket) => (
|
||||
<BrowserLink
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
href={`/companies/${ticket.companyId}/tickets/${ticket.id}`}
|
||||
>
|
||||
{ticket.reference}
|
||||
@ -101,7 +101,7 @@ export function TicketsPage(props: TicketsPageProps) {
|
||||
hideable: false,
|
||||
render: (ticket) => (
|
||||
<BrowserLink
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
href={`/companies/${ticket.companyId}/tickets/${ticket.id}`}
|
||||
>
|
||||
{ticket.values.subject}
|
||||
|
||||
@ -98,7 +98,7 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<BrowserLink
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
href={`/companies/${company.id}/work-profiles/${item.id}`}
|
||||
>
|
||||
{item.values.name}
|
||||
|
||||
@ -9,9 +9,7 @@ export function RouteFeedback({ invalid = false }: { invalid?: boolean }) {
|
||||
{t(invalid ? "navigation.invalidRoute" : "navigation.notFound")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{t("navigation.routeHelp")}</p>
|
||||
<BrowserLink className="text-primary underline underline-offset-4" href="/dashboard">
|
||||
{t("dashboard.title")}
|
||||
</BrowserLink>
|
||||
<BrowserLink href="/dashboard">{t("dashboard.title")}</BrowserLink>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,10 +2,16 @@ import type { ComponentProps } from "react";
|
||||
|
||||
import { isInternalPath, navigateTo } from "../helpers/browser-navigation";
|
||||
|
||||
export function BrowserLink({ href, onClick, ...props }: ComponentProps<"a"> & { href: string }) {
|
||||
export function BrowserLink({
|
||||
href,
|
||||
onClick,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<"a"> & { href: string }) {
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
className={`app-link ${className ?? ""}`}
|
||||
href={href}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
|
||||
@ -33,9 +33,7 @@ export function CustomWorkspace({
|
||||
return (
|
||||
<section className="space-y-4 p-6" role="alert">
|
||||
<p>{t("errors.FORBIDDEN")}</p>
|
||||
<BrowserLink className="underline" href="/dashboard">
|
||||
{t("workspace")}
|
||||
</BrowserLink>
|
||||
<BrowserLink href="/dashboard">{t("workspace")}</BrowserLink>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ export function CatalogName({
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={onView}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@ -72,7 +72,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<BrowserLink
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
href={entityHref(item.id)}
|
||||
>
|
||||
{item.values.name}
|
||||
|
||||
@ -77,10 +77,10 @@ export function FormActions({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** El cuerpo se desplaza dentro del formulario; las acciones conservan su submit nativo. */
|
||||
/** El cuerpo reserva espacio simétrico para que el scroll no recorte el anillo de foco. */
|
||||
export function FormBody({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto pb-5 pr-1" data-slot="form-body">
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-1 pt-1 pb-5" data-slot="form-body">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -180,4 +180,8 @@
|
||||
@apply bg-background text-foreground;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
|
||||
button:not(:disabled), [role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user