diff --git a/.config/vitest.setup.ts b/.config/vitest.setup.ts index 49793f2..bb38ef9 100644 --- a/.config/vitest.setup.ts +++ b/.config/vitest.setup.ts @@ -1,22 +1,28 @@ import React from 'react' +import { afterEach, vi } from 'vitest' + import '@testing-library/jest-dom/vitest' -import { vi } from 'vitest' +import { cleanup } from '@testing-library/react' + +afterEach(() => { + cleanup() +}) ;(global as unknown as Record).React = React Object.defineProperty(window, 'matchMedia', { writable: true, - value: vi.fn().mockImplementation((query: string) => ({ + value: (query: string) => ({ matches: false, media: query, onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn() - })) + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false + }) }) Object.defineProperty(window, 'location', { diff --git a/.env.example b/.env.example index c77b408..ae98bd4 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,6 @@ VITE_REPORT_BASE_URL=http://localhost:3003/reports VITE_RECAPTCHA_SITE_KEY=XXaaxxxxAAAxxxxAAAAAxxd343l VITE_ANALYTICS_APP_ID= + +# Set to "true" to use the redesigned application shell and migrated feature screens. +VITE_REDESIGN_ENABLED=false diff --git a/docs/specs/users-list-redesign.md b/docs/specs/users-list-redesign.md new file mode 100644 index 0000000..04b34d3 --- /dev/null +++ b/docs/specs/users-list-redesign.md @@ -0,0 +1,1771 @@ +# Herbarium UI/UX Redesign Specification + +## Users List — Reference Redesign + +**Status:** Implementation specification +**Frontend:** React + Ant Design 6 +**Initial scope:** Users List +**Design direction:** Modern professional SaaS, minimal, information-dense +**Primary references:** Linear, GitHub +**Priority:** Modern appearance → faster workflows → clearer navigation +**Constraint:** Prefer native Ant Design components and theme tokens over custom components. + +--- + +# 1. Product Context + +The application manages a **herbarium collection** and is used by different types of users, including: + +- Administrators +- Collection curators +- Researchers +- Students +- External users + +Example roles can include: + +- Administrator +- Curator +- Researcher +- Student +- External + +The exact role model is not important for this redesign. The interface must simply support multiple user types cleanly. + +The existing application structure should be preserved where reasonable. This is an **evolution of the current UI**, not a complete product redesign. + +The Users List will be the first redesigned screen and should establish patterns that can later be reused throughout the application. + +--- + +# 2. Current UX Problems + +The redesign must specifically address: + +1. Navigation feels difficult. +2. Forms are confusing. +3. The application looks visually outdated. +4. Filters occupy too much space. +5. Primary and secondary actions do not have enough visual hierarchy. +6. Large containers create unused whitespace. +7. Destructive actions are visually too prominent. +8. The interface does not adapt well to mobile usage. +9. The current application shell uses the institutional green too heavily. +10. The header contains several independent controls that could be simplified. + +--- + +# 3. Design Goals + +The redesigned interface must feel: + +- Modern +- Professional +- Minimal +- Fast +- Dense without feeling crowded +- Consistent +- Familiar to users of modern administrative applications + +The UI should borrow principles from Linear and GitHub rather than attempt to visually copy either product. + +In particular: + +- neutral surfaces; +- restrained use of color; +- clear typography hierarchy; +- compact controls; +- subtle borders; +- minimal shadows; +- contextual actions; +- predictable navigation; +- content taking priority over decorative containers. + +--- + +# 4. Core Design Principle + +## Content First + +The table is the main object of the Users screen. + +Do not surround every section with large cards. + +The hierarchy should primarily be created through: + +- spacing; +- typography; +- borders; +- background contrast; +- alignment. + +Avoid excessive: + +- card nesting; +- shadows; +- oversized headers; +- whitespace; +- decorative UI. + +--- + +# 5. Proposed Desktop Layout + +The general structure should be: + +```text +┌───────────────┬──────────────────────────────────────────────────────┐ +│ │ Top application bar │ +│ ├──────────────────────────────────────────────────────┤ +│ │ │ +│ Sidebar │ Users [+ Add user] │ +│ │ Manage herbarium users │ +│ │ │ +│ │ [ Search users... ] [Role ▼] [Filters] [Columns] │ +│ │ │ +│ │ ┌──────────────────────────────────────────────────┐ │ +│ │ │ Name │ Type │ Email │ Phone │ Created │ ⋯ │ │ +│ │ ├──────────────────────────────────────────────────┤ │ +│ │ │ ... │ │ +│ │ │ ... │ │ +│ │ │ ... │ │ +│ │ └──────────────────────────────────────────────────┘ │ +│ │ Pagination │ +│ │ │ +└───────────────┴──────────────────────────────────────────────────────┘ +``` + +The current large **Search user** card should be removed. + +--- + +# 6. Application Shell + +Although the initial implementation focuses on Users, the shell should already establish the new design language. + +## 6.1 Sidebar + +Keep the left sidebar. + +### Expanded width + +Approximately: + +`232–248px` + +Recommended default: + +`240px` + +### Collapsed width + +Approximately: + +`64–72px` + +### Appearance + +Do **not** use saturated green as the full sidebar background. + +Instead use a neutral sidebar. + +Light mode example: + +```text +Sidebar: #F7F8FA +Main background: #F5F6F8 +Content: #FFFFFF +``` + +Dark mode should use equivalent Ant Design dark-theme surfaces rather than independently hard-coded versions of every color. + +Institutional green becomes the primary accent color. + +Use green for: + +- logo accents; +- selected navigation; +- primary actions; +- links where appropriate; +- focus/accent states; +- small brand details. + +This preserves UTFPR identity without making the application feel visually heavy. + +--- + +# 7. Logo + +The UTFPR logo is mandatory. + +Keep it at the top of the sidebar. + +The logo area should have approximately: + +`64–72px` height. + +Do not make the logo disproportionately large. + +When the sidebar collapses: + +- show a simplified logo/icon if available; +- otherwise maintain an appropriately scaled UTFPR mark. + +--- + +# 8. Navigation Organization + +The sidebar may be reorganized without changing routes. + +Suggested grouping: + +## Collection + +- Accessions +- Taxonomy +- Identifiers +- Collectors +- Herbaria +- Records + +## People & Access + +- Users + +## Geography + +- Locations +- Geolocation + +## Data & Operations + +- Reports +- Export +- Services +- RFID + +The final placement of Services and RFID can be adjusted after reviewing those modules. + +Do not place **Log Out** as a permanent main-navigation item. + +Logout belongs in the account menu. + +--- + +# 9. Sidebar Navigation Behavior + +Selected navigation should use: + +- subtle green-tinted background; +- green foreground/icon; +- medium font weight. + +Avoid a large saturated rectangle across the entire sidebar. + +Hover should use a subtle neutral or lightly tinted background. + +Navigation icons should remain visible because they improve scanning. + +Collapsed sidebar: + +- icons only; +- tooltip on hover; +- active state remains clearly visible. + +On mobile, the sidebar becomes an overlay Drawer opened through the menu button. + +--- + +# 10. Top Application Bar + +Recommended height: + +`56px` + +The current header should be simplified. + +## Left + +- mobile/sidebar toggle when necessary; +- optional breadcrumb. + +## Environment + +The current large: + +`Ambiente de DESENVOLVIMENTO` + +element should become a compact badge/tag such as: + +`Development` + +It should remain clearly visible but should not dominate the navigation. + +Possible semantic treatments: + +- Development → blue/neutral badge +- Staging → orange badge +- Production → no badge or subtle production indicator + +## Right + +Display: + +- language control; +- appearance/theme control; +- user avatar; +- user name on larger screens. + +Clicking the user area opens a menu containing: + +- Profile +- Log out + +Remove the current separate **Profile** and **Log Out** buttons. + +--- + +# 11. Theme Selection + +The application supports three appearance states: + +- System +- Light +- Dark + +Default: + +`System` + +The System option follows the user's operating-system preference. + +A manual Light or Dark selection overrides the operating-system preference. + +Persist the preference locally or in the user's account preferences. + +Do not provide only a binary Light/Dark switch because that removes the System option. + +Recommended control: + +```text +☀ / ☾ appearance icon + ↓ +System +Light +Dark +``` + +--- + +# 12. Main Content Area + +The main content should use substantially more of the available screen. + +Desktop page padding: + +`24px` + +Large desktop: + +up to `32px` if appropriate. + +Mobile: + +`16px` + +Avoid unnecessarily restricting the content with a narrow `max-width`. + +Herbarium management tables benefit from horizontal space. + +--- + +# 13. Users Page Header + +Replace: + +`Users list` + +with: + +# Users + +Optional secondary text: + +`Manage users and access to the herbarium.` + +Do not make the subtitle overly prominent. + +Right side: + +**+ Add user** + +The Add User button is the only primary action in the page header. + +--- + +# 14. Add User + +Clicking **Add user** should open a Drawer. + +Reasoning: + +The form contains approximately 6–10 fields and does not require enough complexity to justify leaving the list. + +Recommended desktop Drawer width: + +approximately `480–560px`. + +The exact width can adapt to the form. + +On mobile: + +the Drawer should use the full available screen width. + +Example structure: + +```text +Add user × +────────────────────────────────── + +Name +[ ] + +Email +[ ] + +Phone +[ ] + +Role +[ ] + +Institution +[ ] + +... + +────────────────────────────────── + Cancel Create +``` + +Use a single-column layout unless two fields have a very strong semantic relationship. + +Primary action: + +**Create user** + +Secondary action: + +**Cancel** + +Validation errors should appear beside or below their fields. + +Do not clear the Drawer if submission fails. + +After successful creation: + +1. close Drawer; +2. show success feedback; +3. refresh the list; +4. display the newly created user if compatible with current filters. + +--- + +# 15. Edit User + +Editing remains a dedicated page. + +Clicking anywhere on a normal table row opens that user's edit page. + +Use: + +`cursor: pointer` + +for clickable rows. + +Interactive elements inside the row must not trigger row navigation. + +Examples: + +- email link; +- overflow menu; +- buttons; +- checkboxes if added later. + +--- + +# 16. Search and Filter Toolbar + +The existing large search form should be replaced by a compact toolbar. + +Desktop example: + +```text +[ 🔍 Search users... ] [ Role ▾ ] [ Filters 2 ] [Columns] +``` + +The Add User action remains in the page header instead of inside the filter toolbar. + +## Search + +One search field replaces: + +- Name +- Email +- Phone + +Placeholder: + +`Search by name, email or phone` + +Search should query those fields simultaneously. + +Support: + +- pressing Enter; +- clicking the search icon/button; +- clearing the search. + +Avoid requiring users to know which field contains the information they are looking for. + +--- + +# 17. Primary Role Filter + +Role/Type is the most frequently useful structured filter and remains directly visible beside Search. + +Example: + +```text +Role +All roles +Administrator +Curator +Researcher +Student +External +``` + +Use the terminology already established by the product. + +If the database uses `type`, it does not require the UI label to say `Type` if `Role` is clearer to users. + +--- + +# 18. Advanced Filters + +Clicking **Filters** opens a compact filtering panel. + +Desktop: + +use a Popover/Dropdown-style panel. + +Mobile: + +use a bottom or side Drawer. + +Filters for the initial version: + +### Institution + +Searchable/selectable institution. + +### Creation date + +Date range: + +```text +From → To +``` + +### Role + +Role can remain visible in the toolbar and does not need to be duplicated in the advanced panel. + +The Filter button should display the number of active advanced filters. + +Example: + +`Filters 2` + +or a small badge. + +Panel actions: + +- Reset +- Apply + +If no advanced filter is active, do not display a count. + +--- + +# 19. Active Filter Visibility + +Users must be able to understand why records are missing. + +If advanced filters are active, optionally show compact removable filter chips below the toolbar. + +Example: + +```text +Institution: UTFPR × Created: Jan 2026 – Aug 2026 × +``` + +Do not show chips for simple keyword search. + +Avoid chips if they make the interface unnecessarily tall when no filters are active. + +--- + +# 20. Column Visibility + +Provide a **Columns** control at the right side of the table toolbar. + +Recommended icon: + +settings/table-columns style icon. + +Click opens a compact panel containing: + +```text +Columns + +✓ Name +✓ Type +✓ E-mail +✓ Phone +✓ Creation date + +Reset to default +``` + +Name should preferably remain mandatory. + +The Actions column should always remain available. + +Column preferences should persist between sessions. + +If account-level preferences already exist, persist there. + +Otherwise local browser storage is sufficient for the first implementation. + +--- + +# 21. Users Table + +Default columns: + +1. Name +2. Type +3. E-mail +4. Phone +5. Creation date +6. Actions + +The existing columns are sufficient. + +--- + +# 22. Table Density + +Prioritize information density. + +Target table row height: + +approximately `40–44px`. + +Use compact/medium Ant Design control sizing. + +Avoid excessively tall SaaS-style rows. + +Headers should be visually clear but not oversized. + +Recommended typography: + +```text +Table header: 12–13px / medium +Table body: 14px / regular +``` + +Use tabular alignment where appropriate. + +--- + +# 23. Table Styling + +Prefer: + +- white/light content surface; +- subtle borders; +- restrained header background; +- row hover state; +- minimal or no shadow. + +Avoid: + +- strong zebra stripes; +- large shadows; +- thick borders; +- oversized rounded table containers. + +Suggested radius for outer table/container: + +`8px` + +The table should visually feel like one surface rather than a card inside another card. + +--- + +# 24. Table Sorting + +Support sorting for useful columns. + +Initial recommendation: + +- Name +- Type +- E-mail +- Creation date + +Creation date should support ascending and descending sorting. + +Do not add sorting merely because technically possible. Phone does not need sorting. + +Sorting state should be visually visible in the column header. + +If the application uses server pagination, sorting should also happen server-side. + +--- + +# 25. Row Hover + +Hovering a row should: + +- slightly change its background; +- show pointer cursor; +- make it clear that the row is clickable. + +Do not use dramatic animation. + +Transition should feel nearly immediate. + +Approximately: + +`100–150ms` + +is sufficient for subtle UI state changes. + +--- + +# 26. Actions Column + +Replace the permanently visible yellow Edit and red Delete icons with a single overflow control: + +`⋯` + +Menu: + +```text +Edit +Delete +``` + +Edit navigates to the same page as clicking the row. + +Keeping Edit in the menu improves discoverability even though the entire row is clickable. + +Delete is styled as destructive. + +This greatly reduces visual noise in a data-dense table. + +--- + +# 27. Delete Confirmation + +Deletion is permanent. + +Never delete immediately after the menu action. + +Show a confirmation Modal. + +Example: + +```text +Delete user? + +Are you sure you want to permanently delete +“Greta Aline Dettke”? + +This action cannot be undone. + +Cancel Delete +``` + +The final Delete button uses danger styling. + +After successful deletion: + +- close Modal; +- display success feedback; +- update the table without requiring a full browser refresh. + +--- + +# 28. Pagination + +Pagination remains below the table. + +Desktop: + +```text +1–20 of 128 users < 1 2 3 ... 7 > 20 / page +``` + +If total-record information already exists, display it. + +Page-size options could include: + +- 20 +- 50 +- 100 + +Default: + +`20` + +If the user changes page size, persist it. + +Keep pagination compact. + +--- + +# 29. Empty State + +Do not render a blank table. + +Two empty states are required. + +## No users exist + +```text +No users yet + +Add the first user to the herbarium. + +[ Add user ] +``` + +## Search/filter returned nothing + +```text +No users found + +Try changing your search or filters. + +[ Clear filters ] +``` + +These are different states and should have different calls to action. + +--- + +# 30. Loading State + +Avoid blocking the entire application shell. + +When refreshing the user list: + +- keep the page structure visible; +- show table loading state/skeleton/spinner; +- avoid layout shifts. + +When applying filters, do not clear the existing table before the request completes if that creates visual flicker. + +--- + +# 31. Error State + +If users cannot be loaded: + +```text +Unable to load users. + +Something went wrong while retrieving the user list. + +[ Try again ] +``` + +Do not expose raw server errors to end users. + +Errors useful for developers may still be logged separately. + +--- + +# 32. Mobile Layout + +Target mobile devices as first-class supported clients, but maintain a denser desktop experience. + +At widths below approximately `768px`: + +- sidebar becomes a Drawer; +- page padding becomes 16px; +- header controls simplify; +- filters reorganize vertically; +- table becomes simplified. + +--- + +# 33. Mobile Page Header + +Example: + +```text +Users [+ Add] +Manage users +``` + +The Add button can shorten from: + +`Add user` + +to: + +`Add` + +when space is limited. + +--- + +# 34. Mobile Filters + +Search becomes full width. + +Example: + +```text +[ 🔍 Search users... ] + +[ Role ▾ ] [ Filters ] [Columns] +``` + +If space becomes too narrow: + +```text +[ 🔍 Search users... ] + +[ Filters ] [Columns] +``` + +Move Role into Filters on very small screens. + +--- + +# 35. Mobile User Table + +Do not use the full six-column desktop table with horizontal scrolling as the default experience. + +Use a simplified table/list. + +Recommended presentation: + +```text +Name / Email Role ⋯ +────────────────────────────────────────── +Greta Aline Dettke Curator ⋯ +greta.aline@gmail.com + +Tatiane Curator ⋯ +tatiane@utfpr.edu.br +``` + +Visible information: + +- Name +- Email as secondary text +- Role +- Actions + +Hide by default: + +- Phone +- Creation date + +Clicking the row still opens Edit User. + +This retains useful context without requiring horizontal scrolling. + +--- + +# 36. Mobile Add User + +Add User Drawer becomes effectively full screen. + +Header remains visible: + +```text +← Add user +``` + +Actions should remain easily reachable. + +If the form becomes taller than the viewport, the content scrolls while the action area may remain sticky. + +--- + +# 37. Typography + +Prefer the default/system-oriented Ant Design typography unless the application already has a required institutional font. + +Do not add a custom web font solely for visual modernization. + +Suggested hierarchy: + +```text +Page title 24px / semibold +Section title 16px / semibold +Body 14px / regular +Secondary 13px / regular +Table header 12–13px / medium +Caption 12px / regular +``` + +Avoid excessive font-weight variation. + +--- + +# 38. Spacing System + +Use a predictable spacing scale. + +Primary spacing values: + +```text +4px +8px +12px +16px +24px +32px +``` + +Examples: + +Page edge → content: + +`24px desktop` + +Page title → toolbar: + +`20–24px` + +Toolbar → table: + +`12–16px` + +Form field spacing: + +`16px` + +Avoid arbitrary spacing such as 17px, 27px, 31px unless necessary. + +--- + +# 39. Border Radius + +Use restrained rounding. + +Recommended: + +```text +Small controls 6px +Inputs/buttons 6–8px +Panels 8px +Large overlays 10–12px +``` + +Avoid the highly rounded/pill-shaped aesthetic for normal form controls. + +Pills are acceptable for: + +- statuses; +- tags; +- small badges. + +--- + +# 40. Brand Color + +Retain UTFPR/institutional green as the brand identity. + +Suggested starting primary seed: + +```text +#0F7A3F +``` + +The exact green should be adjusted against the institution's official branding if an official digital palette exists. + +The goal is not to change the brand to a different color. + +The goal is to **reduce the amount of green used at once**. + +Use green strategically instead of using it as a large background. + +--- + +# 41. Secondary Accent + +The existing gold/yellow should not compete with green as another primary action color. + +Gold may remain for semantic or institutional purposes, but: + +Primary action: + +`Green` + +Secondary actions: + +`Neutral` + +Danger: + +`Red` + +Information: + +Ant Design semantic blue where appropriate. + +Avoid arbitrary button colors. + +--- + +# 42. Light Theme + +Conceptual hierarchy: + +```text +App background light neutral gray +Sidebar light neutral gray +Header white / neutral surface +Content surface white +Border subtle gray +Primary text near-black +Secondary text gray +Accent UTFPR green +``` + +Do not hard-code every derived shade. + +Prefer Ant Design design tokens. + +--- + +# 43. Dark Theme + +Dark mode must be a real theme rather than simply replacing white with black. + +Use Ant Design's dark theme algorithm as the foundation. + +Maintain: + +- neutral dark backgrounds; +- readable text hierarchy; +- subtle surface differentiation; +- green brand accents. + +Avoid highly saturated green surfaces in dark mode. + +Primary green buttons must maintain readable foreground contrast. + +--- + +# 44. Ant Design Theme Strategy + +Create the theme centrally through `ConfigProvider`. + +Prefer global tokens over scattered CSS overrides. + +Conceptual structure: + +```tsx + + + +``` + +Do not create separate manually maintained CSS themes for Light and Dark unless a specific requirement cannot be implemented with Ant Design theming. + +--- + +# 45. Ant Design Component Mapping + +Prefer existing Ant Design components. + +Application shell: + +- `Layout` +- `Sider` +- `Menu` + +Page composition: + +- `Flex` +- `Space` +- `Typography` + +Filters: + +- `Input` / `Input.Search` +- `Select` +- `DatePicker.RangePicker` +- `Popover` or `Dropdown` +- `Badge` + +Actions: + +- `Button` +- `Dropdown` + +Data: + +- `Table` +- `Pagination` + +Creation: + +- `Drawer` +- `Form` +- `Input` +- `Select` + +Feedback: + +- `Modal` +- `App` message/notification APIs +- `Skeleton` / component loading state +- `Empty` + +User navigation: + +- `Avatar` +- `Dropdown` + +Use custom components primarily to compose Ant Design primitives, not replace them. + +--- + +# 46. Internationalization + +The UI must support: + +- English +- Portuguese + +All new visible strings must use the application's translation system. + +Do not hard-code: + +```tsx + +``` + +Prefer: + +```tsx + +``` + +Example key structure: + +```text +users.title +users.description + +users.search.placeholder + +users.filters.role +users.filters.institution +users.filters.creationDate +users.filters.apply +users.filters.reset + +users.columns.name +users.columns.type +users.columns.email +users.columns.phone +users.columns.creationDate + +users.actions.add +users.actions.edit +users.actions.delete + +users.delete.title +users.delete.description +``` + +Layouts must tolerate Portuguese labels being longer than English labels. + +Do not design buttons with fixed widths based on English text. + +--- + +# 47. Navigation Translation + +Menu names must also use translation keys. + +Avoid abbreviations simply because translated strings are longer. + +If necessary: + +- sidebar remains 240px; +- long items use ellipsis; +- tooltip reveals complete label. + +--- + +# 48. Basic Accessibility + +Formal WCAG compliance is not currently a project requirement, but normal accessibility practices should still be followed. + +At minimum: + +- keyboard-accessible controls; +- visible focus states; +- tooltips for icon-only actions; +- sufficient text contrast; +- do not communicate information using color alone; +- buttons must have understandable accessible names; +- clickable table rows must also allow keyboard navigation where practical. + +Do not remove Ant Design focus states for visual reasons. + +--- + +# 49. Interaction Rules + +Use predictable behavior across the page. + +### Primary action + +Only one visually dominant action should normally exist within each context. + +Page: + +`Add user` + +Drawer: + +`Create user` + +Modal: + +`Delete` + +### Secondary actions + +Use default/neutral buttons. + +### Destructive actions + +Use red only when the user reaches the destructive action or confirmation. + +Do not permanently display red icons throughout every table row. + +--- + +# 50. URL and State Behavior + +Whenever possible, list state should be representable in the URL. + +Examples: + +```text +/users?q=marcelo +/users?role=curator +/users?page=2 +/users?sort=createdAt&order=desc +``` + +This is particularly useful for: + +- browser Back; +- browser Forward; +- bookmarking; +- sharing filtered views; +- restoring state after visiting Edit User. + +When the user: + +1. filters Users; +2. opens a user; +3. edits the user; +4. returns to the list; + +their previous: + +- search; +- filters; +- page; +- sorting + +should remain intact. + +This is an important workflow improvement. + +--- + +# 51. Recommended Desktop Screen + +The final Users screen should visually approximate this structure: + +```text +Users + Add user +Manage users and access to the herbarium + +[ Search by name, email or phone... ] [Role ▾] [Filters] [Columns] + +4 users + +┌───────────────────────────────────────────────────────────────────────────┐ +│ Name ▲ Type E-mail Phone Created ⋯ │ +├───────────────────────────────────────────────────────────────────────────┤ +│ Greta Aline Dettke Curator greta...@gmail.com +55 ... Aug 08 ⋯ │ +│ Tatiane Curator tatiane@utfpr... Aug 08 ⋯ │ +│ Marcelo Curator mcaxambu@utfpr... Aug 08 ⋯ │ +│ Edvaldo Szymonek Curator edvaldoszy@gmail... Aug 08 ⋯ │ +└───────────────────────────────────────────────────────────────────────────┘ + +1–4 of 4 users ‹ 1 › 20 / page +``` + +Notice what is intentionally absent: + +- no large Search card; +- no unnecessary section container; +- no permanent Edit icon; +- no permanent Delete icon; +- no giant Add button; +- no saturated green sidebar; +- no duplicated Log Out action. + +--- + +# 52. Visual Relationship to Existing Screen + +The redesign should still be recognizable as the same application. + +Keep: + +- left navigation; +- top header; +- page title; +- filter area; +- table; +- pagination; +- institutional branding; +- general route structure. + +Change: + +- colors; +- density; +- spacing; +- hierarchy; +- filter presentation; +- table interactions; +- navigation grouping; +- account controls; +- theme behavior; +- mobile presentation. + +This satisfies the requirement to be conservative while substantially modernizing the product. + +--- + +# 53. Component Architecture + +Suggested component decomposition: + +```text +AppShell +├── AppSidebar +├── AppHeader +├── PageContent +│ +└── UsersPage + ├── UsersPageHeader + ├── UsersToolbar + │ ├── UserSearch + │ ├── RoleFilter + │ ├── AdvancedFilters + │ └── ColumnSettings + │ + ├── ActiveFilters + ├── UsersTable + ├── UsersPagination + ├── AddUserDrawer + └── DeleteUserModal +``` + +Avoid excessive abstraction. + +A wrapper component should exist only when it: + +- encapsulates reusable behavior; +- provides meaningful semantic structure; +- or will be reused across multiple screens. + +--- + +# 54. Reusable Patterns Established by This Screen + +The Users redesign should establish patterns later reusable by: + +- Accessions +- Taxonomy +- Locations +- Collectors +- Herbaria +- Records +- Reports + +Specifically create reusable patterns for: + +### `PageHeader` + +Title + description + primary action. + +### `ListToolbar` + +Search + common filters + advanced filters + view options. + +### `DataTable` + +Standard density, sorting, row actions and pagination. + +### `EntityDrawer` + +Creation of smaller entities. + +### `ColumnSettings` + +Reusable column visibility configuration. + +This allows future screens to be modernized without creating a different interaction model for every module. + +--- + +# 55. Implementation Rules + +## Prefer Ant Design + +Before creating custom UI, determine whether Ant Design already provides the required component. + +## Prefer theme tokens + +Do not scatter colors such as: + +```css +color: #008000; +background: #f5f5f5; +``` + +through components. + +Use theme tokens or application semantic tokens. + +## Avoid `!important` + +Do not build the redesign around large sets of `!important` overrides. + +## Keep DOM semantic + +Do not turn every UI object into a clickable `div`. + +## Keep responsive logic centralized + +Use common breakpoints and shared layout logic instead of component-specific arbitrary widths. + +--- + +# 56. Suggested Design Tokens + +Create application semantic tokens layered on top of Ant Design tokens. + +Example: + +```ts +const herbariumTheme = { + brand: { + primary: '#0F7A3F', + }, + + layout: { + sidebarWidth: 240, + sidebarCollapsedWidth: 68, + headerHeight: 56, + pagePaddingDesktop: 24, + pagePaddingMobile: 16, + }, + + table: { + rowHeight: 42, + }, +}; +``` + +Do not duplicate Ant Design tokens unnecessarily. + +For example, use Ant Design's normal border, text and surface tokens rather than creating custom equivalents for every color. + +--- + +# 57. Phase 1 Implementation Scope + +Implement: + +- neutral sidebar styling; +- improved sidebar active state; +- simplified application header; +- account dropdown; +- System/Light/Dark theme control; +- Users page header; +- compact search; +- Role filter; +- Advanced Filters; +- column chooser; +- redesigned table; +- sortable columns; +- clickable rows; +- overflow actions; +- delete confirmation; +- pagination; +- Add User Drawer; +- loading/empty/error states; +- mobile simplified users table; +- translations for all new UI. + +Do not redesign every other module during Phase 1. + +They may continue using their existing screens inside the new application shell until migrated. + +--- + +# 58. Phase 1 Non-Goals + +Do not: + +- rebuild the backend; +- change user permissions; +- change database models; +- redesign the Edit User form unless necessary for shell compatibility; +- introduce a new UI library; +- replace Ant Design; +- create a custom design-system package; +- redesign every herbarium module simultaneously; +- radically change route structure. + +--- + +# 59. Acceptance Criteria + +The Users redesign is complete when all of the following are true: + +## Application shell + +- [ ] UTFPR logo remains present. +- [ ] Sidebar uses a neutral surface rather than solid saturated green. +- [ ] Green remains the primary brand color. +- [ ] Sidebar can collapse on desktop. +- [ ] Sidebar becomes an overlay on mobile. +- [ ] Navigation is grouped logically. +- [ ] Logout is available through the account menu. +- [ ] Header is approximately 56px high. +- [ ] Development environment indicator is compact. + +## Theme + +- [ ] System appearance is the default. +- [ ] User can manually select Light. +- [ ] User can manually select Dark. +- [ ] Appearance preference persists. +- [ ] Dark mode uses Ant Design theming rather than a separately duplicated CSS theme. + +## Users header + +- [ ] Page title is `Users`. +- [ ] Add User is the primary page action. +- [ ] Add User opens a Drawer. +- [ ] Edit User remains a dedicated route. + +## Search + +- [ ] One input searches name, email and phone. +- [ ] Individual Name/Email/Phone search inputs have been removed. +- [ ] Role is available as a quick filter. +- [ ] Advanced Filters contains Institution and Creation Date. +- [ ] Active filters can easily be cleared. + +## Table + +- [ ] Default columns remain Name, Type, E-mail, Phone and Creation Date. +- [ ] Rows are approximately 40–44px high. +- [ ] Useful columns support sorting. +- [ ] User can choose visible columns. +- [ ] Column preferences persist. +- [ ] Clicking a row opens Edit User. +- [ ] Row hover makes clickability clear. +- [ ] Action column uses an overflow menu. +- [ ] Delete requires confirmation. +- [ ] Pagination is compact. +- [ ] Page size can be selected. + +## State + +- [ ] Loading state exists. +- [ ] Empty database state exists. +- [ ] No-search-results state exists. +- [ ] Error state exists. +- [ ] Successful creation provides feedback. +- [ ] Successful deletion provides feedback. + +## Responsive behavior + +- [ ] Page works on desktop. +- [ ] Page works on mobile. +- [ ] Desktop table remains information-dense. +- [ ] Mobile table does not require six-column horizontal scrolling. +- [ ] Mobile rows show Name, Email and Role. +- [ ] Add User Drawer works as a full-width mobile experience. + +## Internationalization + +- [ ] All new text uses translation keys. +- [ ] English is supported. +- [ ] Portuguese is supported. +- [ ] Components tolerate longer translations. + +--- + +# 60. Definition of Success + +The redesign should not make users think: + +> “This is a completely different system.” + +It should make them think: + +> “This is the same herbarium system, but cleaner, faster and much more modern.” + +The strongest visual change should come from **removing unnecessary UI**, not adding more decoration. + +The Users List should become the reference implementation for the rest of the herbarium application. \ No newline at end of file diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 4b1bf03..252dc04 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -7,7 +7,7 @@ "pagina": "page", "paginas": "pages", - + "adicionar": "Add", "excluir": "Delete", "cadastrar": "Register", @@ -21,7 +21,7 @@ "atualizacaoRealizadaSucesso": "The update was completed successfully.", "notificacaoExcluir": "Delete", - + "tituloFalha": "Failure", "erro": "Error", "error": "Error", @@ -32,5 +32,9 @@ "nenhumResultadoEncontrado": "No results found", "sim": "YES", - "nao": "NO" + "nao": "NO", + + "page": { + "title": "{{title}} - Herbário UTFPR Campo Mourão" + } } diff --git a/public/locales/en/dashboardScreen.json b/public/locales/en/dashboardScreen.json index f6080af..3b2d00c 100644 --- a/public/locales/en/dashboardScreen.json +++ b/public/locales/en/dashboardScreen.json @@ -1,5 +1,5 @@ { - "totalTombos": "Total Records", + "totalTombos": "Total Accessions", "internos": "Internal", "externos": "External", "fotos": "Photos", diff --git a/public/locales/en/fichaTomboActions.json b/public/locales/en/fichaTomboActions.json index f1a9ad9..9fc9388 100644 --- a/public/locales/en/fichaTomboActions.json +++ b/public/locales/en/fichaTomboActions.json @@ -1,19 +1,19 @@ { - "labelComCodigo": "Print Record Sheet (with barcode)", + "labelComCodigo": "Print Accession Sheet (with barcode)", "titleComCodigo": "Print sheet with barcode", - "labelSemCodigo": "Print Record Sheet (without barcode)", + "labelSemCodigo": "Print Accession Sheet (without barcode)", "titleSemCodigo": "Print sheet without barcode", - "labelReduzida": "Print Reduced Record Sheet", + "labelReduzida": "Print Reduced Accession Sheet", "titleReduzida": "Print reduced sheet", "erroCarregarCodigos": "Unable to load the barcodes.", "impressaoIniciada": "Printing started!", "erroIniciarImpressao": "Unable to start printing.", - "tombo": "Record:", + "tombo": "Accession:", "quantidadeCopias": "Number of copies", "informeQuantidade": "Enter the number of copies", "permitidoEntre": "Allowed between 1 and 3", "carregandoCodigos": "Loading barcodes...", - "nenhumCodigo": "No barcode available for this record.", + "nenhumCodigo": "No barcode available for this accession.", "codigoBarras": "Barcode", "selecioneCodigo": "Select a barcode", "placeholderSelecione": "Select the code", diff --git a/public/locales/en/listaPendenciasScreen.json b/public/locales/en/listaPendenciasScreen.json index a6cd080..ef4930f 100644 --- a/public/locales/en/listaPendenciasScreen.json +++ b/public/locales/en/listaPendenciasScreen.json @@ -1,6 +1,6 @@ { "titulo": "Pending items list", - "colNumeroTombo": "Record No.", + "colNumeroTombo": "Accession No.", "colNomeUsuario": "User name", "colDataCriacao": "Creation date", "colStatus": "Status", diff --git a/public/locales/en/listaRemessasScreen.json b/public/locales/en/listaRemessasScreen.json index 3fb52ce..188f53f 100644 --- a/public/locales/en/listaRemessasScreen.json +++ b/public/locales/en/listaRemessasScreen.json @@ -8,7 +8,7 @@ "colunaObservacao": "Notes", "colunaAcao": "Action", - "subColunaTombo": "Voucher", + "subColunaTombo": "Accession", "subColunaTipo": "Type", "subColunaDataVencimento": "Due date", "subColunaAcao": "Action", @@ -17,7 +17,7 @@ "buscarRemessas": "Search shipments", "numeroRemessa": "Shipment number:", - "numeroTombo": "Voucher number:", + "numeroTombo": "Accession number:", "herbario": "Herbarium:", "selecioneHerbario": "Select", @@ -26,9 +26,9 @@ "sucessoExcluirRemessa": "The shipment has been successfully deleted.", "tituloExcluirRemessa": "Delete shipment", - "confirmarDevolverTombo": "Are you sure you want to return this voucher?", - "descricaoDevolverTombo": "By clicking YES, the voucher will be returned.", - "sucessoDevolverTombo": "The voucher has been successfully returned.", - "tituloDevolverTombo": "Return voucher", - "erroDevolverTombo": "Error returning voucher" + "confirmarDevolverTombo": "Are you sure you want to return this accession?", + "descricaoDevolverTombo": "By clicking YES, the accession will be returned.", + "sucessoDevolverTombo": "The accession has been successfully returned.", + "tituloDevolverTombo": "Return accession", + "erroDevolverTombo": "Error returning accession" } diff --git a/public/locales/en/listaTombosScreen.json b/public/locales/en/listaTombosScreen.json index 8817218..b5dce5c 100644 --- a/public/locales/en/listaTombosScreen.json +++ b/public/locales/en/listaTombosScreen.json @@ -1,12 +1,12 @@ { - "titulo": "Records", + "titulo": "Accessions", "colHcf": "HCF", "colNomePopular": "Common name", "colNomeCientifico": "Scientific name", "colDataColeta": "Collection date", "colColetor": "Collector", "colAcao": "Action", - "buscarTombo": "Search record", + "buscarTombo": "Search accession", "labelHcf": "HCF:", "labelTipo": "Type:", "placeholderSelecione": "Select", @@ -23,18 +23,18 @@ "placeholderNomePopular": "Passion fruit", "labelCodigoBarraFoto": "Photo barcode:", "placeholderCodigoBarraFoto": "Enter the barcode", - "notifExcluirTombo": "Delete record", - "tomboExcluido": "The record was deleted successfully.", - "erroExcluirTombo": "There was a problem deleting the record, please try again.", - "confirmExcluirTitulo": "Are you sure you want to delete this record?", - "confirmExcluirConteudo": "By clicking YES, the record will be deleted.", - "notifBuscarTombos": "Search records", - "erroBuscarTombos": "Error fetching the records.", - "erroBuscarTombosFalha": "Failed to fetch records.", + "notifExcluirTombo": "Delete accession", + "tomboExcluido": "The accession was deleted successfully.", + "erroExcluirTombo": "There was a problem deleting the accession, please try again.", + "confirmExcluirTitulo": "Are you sure you want to delete this accession?", + "confirmExcluirConteudo": "By clicking YES, the accession will be deleted.", + "notifBuscarTombos": "Search accessions", + "erroBuscarTombos": "Error fetching the accessions.", + "erroBuscarTombosFalha": "Failed to fetch accessions.", "alerta": "Alert", "erroMinMaxColunas": "Select a minimum of 1 and a maximum of 5 columns to export.", - "erroInformePeriodo": "Enter a range, specific records or a date range to export", - "erroExportacaoUnica": "Data export must be done by a range, specific records or a date range, never using two or more", + "erroInformePeriodo": "Enter a range, specific accessions or a date range to export", + "erroExportacaoUnica": "Data export must be done by a range, specific accessions or a date range, never using two or more", "panelHeaderCampos": "Select the fields to be exported:", "tagHcf": "HCF", "tagDataColeta": "Collection Date", @@ -44,7 +44,7 @@ "tagEspecie": "Species", "tagSubespecie": "Subspecies", "tagVariedade": "Variety", - "tagSequencia": "Record sequence", + "tagSequencia": "Accession sequence", "tagCodigoBarra": "Barcode", "tagLatitude": "Latitude", "tagLongitude": "Longitude", @@ -54,10 +54,10 @@ "aPartirDe": "From:", "ate": "To:", "ou": "OR", - "numerosTombos": "Record numbers to be exported:", - "aPartirDataTombo": "From the record date:", - "placeholderDataInicial": "Select the initial record date", - "ateDataTombo": "To the record date:", - "placeholderDataFinal": "Select the final record date", + "numerosTombos": "Accession numbers to be exported:", + "aPartirDataTombo": "From the accession date:", + "placeholderDataInicial": "Select the initial accession date", + "ateDataTombo": "To the accession date:", + "placeholderDataFinal": "Select the final accession date", "exportar": "Export" } diff --git a/public/locales/en/listaUsuariosPage.json b/public/locales/en/listaUsuariosPage.json new file mode 100644 index 0000000..40873f9 --- /dev/null +++ b/public/locales/en/listaUsuariosPage.json @@ -0,0 +1,74 @@ +{ + "title": "Users", + "description": "Manage users and access to the herbarium.", + + "search": { + "placeholder": "Search by name, email or phone" + }, + + "filters": { + "role": "Role", + "allRoles": "All roles", + "trigger": "Filters" + }, + + "columns": { + "title": "Columns", + "trigger": "Columns", + "reset": "Reset to default", + "name": "Name", + "type": "Type", + "email": "E-mail", + "phone": "Phone", + "creationDate": "Creation date", + "actions": "Actions" + }, + + "roles": { + "curator": "Curator", + "operator": "Operator", + "identifier": "Identifier" + }, + + "actions": { + "add": "Add user", + "addShort": "Add", + "edit": "Edit", + "delete": "Delete", + "menu": "Row actions" + }, + + "create": { + "title": "Add user", + "submit": "Create user", + "success": "The user has been created successfully.", + "errorTitle": "Could not create user", + "error": "Something went wrong while creating the user. Please try again." + }, + + "delete": { + "title": "Delete user?", + "description": "Are you sure you want to permanently delete “{{name}}”? This action cannot be undone.", + "success": "The user has been deleted successfully.", + "errorTitle": "Could not delete user", + "error": "Something went wrong while deleting the user. Please try again." + }, + + "empty": { + "title": "No users yet", + "description": "Add the first user to the herbarium.", + "filteredTitle": "No users found", + "filteredDescription": "Try changing your search or filters.", + "clearFilters": "Clear filters" + }, + + "error": { + "title": "Unable to load users.", + "description": "Something went wrong while retrieving the user list.", + "retry": "Try again" + }, + + "pagination": { + "total": "{{from}}–{{to}} of {{total}} users" + } +} diff --git a/public/locales/en/mainLayout.json b/public/locales/en/mainLayout.json index 8530be7..8d369b2 100644 --- a/public/locales/en/mainLayout.json +++ b/public/locales/en/mainLayout.json @@ -25,7 +25,7 @@ "mapaCompleto": "Full Map", "filtrosAvancados": "Advanced Filters", "relatorios": "Reports", - "relatorioPorPeriodo": "Period Report", + "relatorioPorPeriodo": "Report by Period", "inventarioEspecies": "Species Inventory", "coletaIntervaloData": "Collections by Date Range", "coletaColetorIntervaloData": "Collections by Collector and Date Range", @@ -37,5 +37,17 @@ "diagnosticoErrosPosicionamento": "Positioning Error Diagnosis", "exportacao": "Export", "servicos": "Services", - "idioma": "Language" + "idioma": "Language", + "grupoColecao": "Collection", + "grupoPessoasAcesso": "People & Access", + "grupoGeografia": "Geography", + "grupoDadosOperacoes": "Data & Operations", + "ambienteDesenvolvimento": "Development", + "ambienteStaging": "Staging", + "aparencia": "Appearance", + "aparenciaSistema": "System", + "aparenciaClara": "Light", + "aparenciaEscura": "Dark", + "alternarMenu": "Toggle navigation", + "menuConta": "Account menu" } diff --git a/public/locales/en/novaRemessaScreen.json b/public/locales/en/novaRemessaScreen.json index d9d8885..97e3a60 100644 --- a/public/locales/en/novaRemessaScreen.json +++ b/public/locales/en/novaRemessaScreen.json @@ -1,13 +1,13 @@ { "titulo": "Shipment", - "colunaTombo": "Voucher", + "colunaTombo": "Accession", "colunaTipo": "Type", "colunaDataVencimento": "Due date", "colunaAcao": "Action", - "modalTitulo": "Add voucher to shipment", - "numeroTombo": "Voucher no.:", + "modalTitulo": "Add accession to shipment", + "numeroTombo": "Accession No.:", "tipo": "Type:", "dataVencimento": "Due date:", "selecioneTipo": "Select a type", @@ -25,13 +25,13 @@ "dataEnvio": "Shipping date:", "observacao": "Notes:", - "adicionarTombo": "Add voucher", + "adicionarTombo": "Add accession", "salvar": "Save", "erroBuscarRemessa": "There was a problem fetching the shipment data, please try again.", "erroBuscarHerbarios": "Error fetching the herbaria list.", - "validacaoAdicionarTombo": "You must add at least one voucher to the shipment list.", + "validacaoAdicionarTombo": "You must add at least one accession to the shipment list.", "tituloCadastroAlteracao": "Register/Update", "sucessoCadastro": "Registration completed successfully.", @@ -41,6 +41,6 @@ "tituloEdicao": "Edit", "erroEdicao": "There was a problem editing, please check the data and try again.", - "confirmarExcluirTombo": "Are you sure you want to remove this voucher from the list?", - "descricaoExcluirTombo": "By clicking YES, the voucher will be removed." + "confirmarExcluirTombo": "Are you sure you want to remove this accession from the list?", + "descricaoExcluirTombo": "By clicking YES, the accession will be removed." } diff --git a/public/locales/en/relatorioCoordenadaForaPoligonoScreen.json b/public/locales/en/relatorioCoordenadaForaPoligonoScreen.json index ef764c1..cd84921 100644 --- a/public/locales/en/relatorioCoordenadaForaPoligonoScreen.json +++ b/public/locales/en/relatorioCoordenadaForaPoligonoScreen.json @@ -8,7 +8,7 @@ "selecioneEstado": "Select a state", "cidade": "City:", "selecioneCidade": "Select a city", - "incluirSemCoordenadas": "Include vouchers without coordinates", + "incluirSemCoordenadas": "Include accession without coordinates", "gerarPDF": "Generate PDF", @@ -24,7 +24,7 @@ "motivoForaDoPoligono": "Outside polygon", "nenhumRegistro": "No records found.", - "tombos": "voucher(s)", + "tombos": "accession(s)", "erroBuscarPaises": "Failed to fetch countries.", "erroBuscarEstados": "Failed to fetch states.", diff --git a/public/locales/en/relatorioInventarioEspecies.json b/public/locales/en/relatorioInventarioEspecies.json index 09fff9c..a8872f6 100644 --- a/public/locales/en/relatorioInventarioEspecies.json +++ b/public/locales/en/relatorioInventarioEspecies.json @@ -18,7 +18,7 @@ "erroDeServidorAoExportarOPdf": "Server error while exporting PDF.", "especie": "Species", - "tombosRelacionados": "Related records", + "tombosRelacionados": "Related accessions", "error": "Error" } diff --git a/public/locales/en/relatorioTombosPorCidadeScreen.json b/public/locales/en/relatorioTombosPorCidadeScreen.json index 45f41ca..5e02517 100644 --- a/public/locales/en/relatorioTombosPorCidadeScreen.json +++ b/public/locales/en/relatorioTombosPorCidadeScreen.json @@ -1,5 +1,5 @@ { - "titulo": "Vouchers by City Report", + "titulo": "Acession Report by City", "filtros": "Report filters", "pais": "Country:", diff --git a/public/locales/en/rfid.json b/public/locales/en/rfid.json index f032a80..c5f71e9 100644 --- a/public/locales/en/rfid.json +++ b/public/locales/en/rfid.json @@ -75,13 +75,13 @@ "title": "RFID Conference", "filtersTitle": "Conference Filters", "id": "ID", - "tombo": "Tombo", + "tombo": "Accession", "barcode": "Barcode", "createdAt": "Creation date", "updatedAt": "Last update date", "status": "Status", "statusReading": "Reading status", - "searchTombo": "Search by Tombo...", + "searchTombo": "Search by Acession...", "searchBarcode": "Search by barcode...", "searchEpc": "Search by EPC...", "completed": "Completed", @@ -95,7 +95,7 @@ "identified": "Identified", "unknown": "Unknown", "notIdentified": "Not identified", - "tombo": "Tombo", + "tombo": "Accession", "collector": "Main Collector", "totalReads": "Total reads", "actions": "Actions", @@ -114,8 +114,8 @@ "link": { "title": "RFID Linking", "selectPhoto": "Select photo", - "searchPlaceholder": "Search by tombo/barcode", - "noTomboFound": "No tombo found", + "searchPlaceholder": "Search by accession/barcode", + "noTomboFound": "No accession found", "confirmation": "Confirmation", "details": "Details", "barcode": "Barcode", @@ -154,7 +154,7 @@ "readingStartedDescription": "Bring the tags close to the reader.", "connectionLostTitle": "Connection Lost", "communicationStopped": "The RFID communication service stopped.", - "tombosFetchFailure": "Failed to fetch tombos for linking.", + "tombosFetchFailure": "Failed to fetch acessions for linking.", "deviceUnavailableTitle": "Device unavailable", "configureDeviceBeforeWrite": "Configure the device before writing.", "checkingReaderCommunication": "Checking communication with the RFID reader...", diff --git a/public/locales/en/simpleTableComponent.json b/public/locales/en/simpleTableComponent.json index cdbd25b..f4431a9 100644 --- a/public/locales/en/simpleTableComponent.json +++ b/public/locales/en/simpleTableComponent.json @@ -14,5 +14,8 @@ "ordenacaoDecrescente": "Click to sort descending", "ordenacaoCrescente": "Click to sort ascending", - "cancelarOrdenacao": "Cancel sorting" + "cancelarOrdenacao": "Cancel sorting", + "intervaloTotal": "{{from}}–{{to}} of {{total}}", + "colunas": "Columns", + "restaurarColunas": "Restore defaults" } diff --git a/public/locales/en/tableColetaPorLocalData.json b/public/locales/en/tableColetaPorLocalData.json index bc469a9..bf17f5b 100644 --- a/public/locales/en/tableColetaPorLocalData.json +++ b/public/locales/en/tableColetaPorLocalData.json @@ -3,5 +3,5 @@ "colFamilia": "Family", "colEspecie": "Species", "colAutor": "Author", - "colTombo": "Record" + "colTombo": "Accession" } diff --git a/public/locales/es/common.json b/public/locales/es/common.json index 25e1f45..1c1e426 100644 --- a/public/locales/es/common.json +++ b/public/locales/es/common.json @@ -22,5 +22,9 @@ "erroComunicacaoServidor": "Fallo en la comunicación con el servidor.", "nenhumResultadoEncontrado": "No se encontraron resultados", "sim": "SÍ", - "nao": "NO" + "nao": "NO", + + "page": { + "title": "{{title}} - Herbário UTFPR Campo Mourão" + } } diff --git a/public/locales/es/dashboardScreen.json b/public/locales/es/dashboardScreen.json index ae460cf..c188c3d 100644 --- a/public/locales/es/dashboardScreen.json +++ b/public/locales/es/dashboardScreen.json @@ -1,5 +1,5 @@ { - "totalTombos": "Total de Registros", + "totalTombos": "Total de Ejemplares", "internos": "Internos", "externos": "Externos", "fotos": "Fotos", diff --git a/public/locales/es/fichaTomboActions.json b/public/locales/es/fichaTomboActions.json index f1a9ad9..88b7d44 100644 --- a/public/locales/es/fichaTomboActions.json +++ b/public/locales/es/fichaTomboActions.json @@ -1,21 +1,21 @@ { - "labelComCodigo": "Print Record Sheet (with barcode)", - "titleComCodigo": "Print sheet with barcode", - "labelSemCodigo": "Print Record Sheet (without barcode)", - "titleSemCodigo": "Print sheet without barcode", - "labelReduzida": "Print Reduced Record Sheet", - "titleReduzida": "Print reduced sheet", - "erroCarregarCodigos": "Unable to load the barcodes.", - "impressaoIniciada": "Printing started!", - "erroIniciarImpressao": "Unable to start printing.", - "tombo": "Record:", - "quantidadeCopias": "Number of copies", - "informeQuantidade": "Enter the number of copies", - "permitidoEntre": "Allowed between 1 and 3", - "carregandoCodigos": "Loading barcodes...", - "nenhumCodigo": "No barcode available for this record.", - "codigoBarras": "Barcode", - "selecioneCodigo": "Select a barcode", - "placeholderSelecione": "Select the code", - "imprimir": "Print" + "labelComCodigo": "Imprimir ficha de ejemplar (con código de barras)", + "titleComCodigo": "Imprimir ficha con código de barras", + "labelSemCodigo": "Imprimir ficha de ejemplar (sin código de barras)", + "titleSemCodigo": "Imprimir ficha sin código de barras", + "labelReduzida": "Imprimir ficha reducida de ejemplar", + "titleReduzida": "Imprimir ficha reducida", + "erroCarregarCodigos": "No fue posible cargar los códigos de barras.", + "impressaoIniciada": "¡Impresión iniciada!", + "erroIniciarImpressao": "No fue posible iniciar la impresión.", + "tombo": "Ejemplar:", + "quantidadeCopias": "Cantidad de copias", + "informeQuantidade": "Ingrese la cantidad de copias", + "permitidoEntre": "Permitido entre 1 y 3", + "carregandoCodigos": "Cargando códigos de barras...", + "nenhumCodigo": "No hay código de barras disponible para este ejemplar.", + "codigoBarras": "Código de barras", + "selecioneCodigo": "Seleccione un código de barras", + "placeholderSelecione": "Seleccione el código", + "imprimir": "Imprimir" } diff --git a/public/locales/es/fichaTomboScreen.json b/public/locales/es/fichaTomboScreen.json index d254543..1816aea 100644 --- a/public/locales/es/fichaTomboScreen.json +++ b/public/locales/es/fichaTomboScreen.json @@ -1,13 +1,13 @@ { - "titulo": "Record Sheet", + "titulo": "Ficha de ejemplar", "colHcf": "HCF", - "colDataColeta": "Collection date", - "colNomeCientifico": "Scientific name", - "colAcao": "Action", + "colDataColeta": "Fecha de colecta", + "colNomeCientifico": "Nombre científico", + "colAcao": "Acción", "labelHcf": "HCF:", - "labelDataColeta": "Collection date:", - "labelNomeCientifico": "Scientific name:", - "tituloModal": "Sheet printing", - "imprimir": "Print", - "informeNumeroCopias": "Enter the number of copies:" + "labelDataColeta": "Fecha de colecta:", + "labelNomeCientifico": "Nombre científico:", + "tituloModal": "Impresión de ficha", + "imprimir": "Imprimir", + "informeNumeroCopias": "Ingrese la cantidad de copias:" } diff --git a/public/locales/es/listaPendenciasScreen.json b/public/locales/es/listaPendenciasScreen.json index 766d898..c2b3bde 100644 --- a/public/locales/es/listaPendenciasScreen.json +++ b/public/locales/es/listaPendenciasScreen.json @@ -1,6 +1,6 @@ { "titulo": "Lista de pendencias", - "colNumeroTombo": "N.º Registro", + "colNumeroTombo": "N.º Catálogo", "colNomeUsuario": "Nombre de usuario", "colDataCriacao": "Fecha de creación", "colStatus": "Estado", diff --git a/public/locales/es/listaRemessasScreen.json b/public/locales/es/listaRemessasScreen.json index e7135ce..fa23be6 100644 --- a/public/locales/es/listaRemessasScreen.json +++ b/public/locales/es/listaRemessasScreen.json @@ -8,7 +8,7 @@ "colunaObservacao": "Observación", "colunaAcao": "Acción", - "subColunaTombo": "Voucher", + "subColunaTombo": "Ejemplar", "subColunaTipo": "Tipo", "subColunaDataVencimento": "Fecha de vencimiento", "subColunaAcao": "Acción", @@ -17,7 +17,7 @@ "buscarRemessas": "Buscar remesas", "numeroRemessa": "Número de remesa:", - "numeroTombo": "Número del voucher:", + "numeroTombo": "Número de catálogo:", "herbario": "Herbario:", "selecioneHerbario": "Seleccione", @@ -26,9 +26,9 @@ "sucessoExcluirRemessa": "La remesa fue eliminada con éxito.", "tituloExcluirRemessa": "Eliminar remesa", - "confirmarDevolverTombo": "¿Está seguro de que desea devolver este voucher?", - "descricaoDevolverTombo": "Al hacer clic en SÍ, el voucher será devuelto.", - "sucessoDevolverTombo": "El voucher fue devuelto con éxito.", - "tituloDevolverTombo": "Devolver voucher", - "erroDevolverTombo": "Error al devolver el voucher" + "confirmarDevolverTombo": "¿Está seguro de que desea devolver este ejemplar?", + "descricaoDevolverTombo": "Al hacer clic en SÍ, el ejemplar será devuelto.", + "sucessoDevolverTombo": "El ejemplar fue devuelto con éxito.", + "tituloDevolverTombo": "Devolver ejemplar", + "erroDevolverTombo": "Error al devolver el ejemplar" } diff --git a/public/locales/es/listaTombosScreen.json b/public/locales/es/listaTombosScreen.json index 22e0566..e489a6d 100644 --- a/public/locales/es/listaTombosScreen.json +++ b/public/locales/es/listaTombosScreen.json @@ -1,12 +1,12 @@ { - "titulo": "Registros", + "titulo": "Ejemplares", "colHcf": "HCF", "colNomePopular": "Nombre popular", "colNomeCientifico": "Nombre científico", "colDataColeta": "Fecha de colecta", "colColetor": "Colector", "colAcao": "Acción", - "buscarTombo": "Buscar registro", + "buscarTombo": "Buscar ejemplar", "labelHcf": "HCF:", "labelTipo": "Tipo:", "placeholderSelecione": "Seleccione", @@ -23,18 +23,18 @@ "placeholderNomePopular": "Maracuyá", "labelCodigoBarraFoto": "Código de barras de la foto:", "placeholderCodigoBarraFoto": "Escriba el código de barras", - "notifExcluirTombo": "Eliminar registro", - "tomboExcluido": "El registro fue eliminado con éxito.", - "erroExcluirTombo": "Hubo un problema al eliminar el registro, inténtelo de nuevo.", - "confirmExcluirTitulo": "¿Está seguro de que desea eliminar este registro?", - "confirmExcluirConteudo": "Al hacer clic en SÍ, el registro será eliminado.", - "notifBuscarTombos": "Buscar registros", - "erroBuscarTombos": "Error al buscar los registros.", - "erroBuscarTombosFalha": "Error al buscar registros.", + "notifExcluirTombo": "Eliminar ejemplar", + "tomboExcluido": "El ejemplar fue eliminado con éxito.", + "erroExcluirTombo": "Hubo un problema al eliminar el ejemplar, inténtelo de nuevo.", + "confirmExcluirTitulo": "¿Está seguro de que desea eliminar este ejemplar?", + "confirmExcluirConteudo": "Al hacer clic en SÍ, el ejemplar será eliminado.", + "notifBuscarTombos": "Buscar ejemplares", + "erroBuscarTombos": "Error al buscar los ejemplares.", + "erroBuscarTombosFalha": "Error al buscar ejemplares.", "alerta": "Alerta", "erroMinMaxColunas": "Seleccione un mínimo de 1 y un máximo de 5 columnas para exportar.", - "erroInformePeriodo": "Indique un período, registros específicos o un intervalo de fechas para exportar", - "erroExportacaoUnica": "La exportación de los datos debe hacerse por un período, registros específicos o un intervalo de fechas, nunca utilizando dos o más", + "erroInformePeriodo": "Indique un período, ejemplares específicos o un intervalo de fechas para exportar", + "erroExportacaoUnica": "La exportación de los datos debe hacerse por un período, ejemplares específicos o un intervalo de fechas, nunca utilizando dos o más", "panelHeaderCampos": "Seleccione los campos a exportar:", "tagHcf": "HCF", "tagDataColeta": "Fecha de Colecta", @@ -44,7 +44,7 @@ "tagEspecie": "Especie", "tagSubespecie": "Subespecie", "tagVariedade": "Variedad", - "tagSequencia": "Secuencia del registro", + "tagSequencia": "Secuencia del ejemplar", "tagCodigoBarra": "Código de barras", "tagLatitude": "Latitud", "tagLongitude": "Longitud", @@ -54,10 +54,10 @@ "aPartirDe": "A partir de:", "ate": "Hasta:", "ou": "O", - "numerosTombos": "Números de los registros a exportar:", - "aPartirDataTombo": "A partir de la fecha de registro:", - "placeholderDataInicial": "Seleccione la fecha de registro inicial", - "ateDataTombo": "Hasta la fecha de registro:", - "placeholderDataFinal": "Seleccione la fecha de registro final", + "numerosTombos": "Números de los ejemplares a exportar:", + "aPartirDataTombo": "A partir de la fecha de ejemplar:", + "placeholderDataInicial": "Seleccione la fecha de ejemplar inicial", + "ateDataTombo": "Hasta la fecha de ejemplar:", + "placeholderDataFinal": "Seleccione la fecha de ejemplar final", "exportar": "Exportar" } diff --git a/public/locales/es/listaUsuariosPage.json b/public/locales/es/listaUsuariosPage.json new file mode 100644 index 0000000..80fc0c1 --- /dev/null +++ b/public/locales/es/listaUsuariosPage.json @@ -0,0 +1,74 @@ +{ + "title": "Usuarios", + "description": "Administre usuarios y el acceso al herbario.", + + "search": { + "placeholder": "Buscar por nombre, correo o teléfono" + }, + + "filters": { + "role": "Rol", + "allRoles": "Todos los roles", + "trigger": "Filtros" + }, + + "columns": { + "title": "Columnas", + "trigger": "Columnas", + "reset": "Restablecer predeterminado", + "name": "Nombre", + "type": "Tipo", + "email": "Correo electrónico", + "phone": "Teléfono", + "creationDate": "Fecha de creación", + "actions": "Acciones" + }, + + "roles": { + "curator": "Curador", + "operator": "Operador", + "identifier": "Identificador" + }, + + "actions": { + "add": "Añadir usuario", + "addShort": "Añadir", + "edit": "Editar", + "delete": "Eliminar", + "menu": "Acciones de la fila" + }, + + "create": { + "title": "Añadir usuario", + "submit": "Crear usuario", + "success": "El usuario se creó correctamente.", + "errorTitle": "No se pudo crear el usuario", + "error": "Ocurrió un problema al crear el usuario. Inténtelo de nuevo." + }, + + "delete": { + "title": "¿Eliminar usuario?", + "description": "¿Seguro que desea eliminar permanentemente a “{{name}}”? Esta acción no se puede deshacer.", + "success": "El usuario se eliminó correctamente.", + "errorTitle": "No se pudo eliminar el usuario", + "error": "Ocurrió un problema al eliminar el usuario. Inténtelo de nuevo." + }, + + "empty": { + "title": "Todavía no hay usuarios", + "description": "Añada el primer usuario al herbario.", + "filteredTitle": "No se encontraron usuarios", + "filteredDescription": "Pruebe a cambiar la búsqueda o los filtros.", + "clearFilters": "Limpiar filtros" + }, + + "error": { + "title": "No se pudieron cargar los usuarios.", + "description": "Ocurrió un problema al obtener la lista de usuarios.", + "retry": "Intentar de nuevo" + }, + + "pagination": { + "total": "{{from}}–{{to}} de {{total}} usuarios" + } +} diff --git a/public/locales/es/livroTombo.json b/public/locales/es/livroTombo.json index 798ef3b..e143b47 100644 --- a/public/locales/es/livroTombo.json +++ b/public/locales/es/livroTombo.json @@ -1,5 +1,5 @@ { - "tituloLivroTombo": "Libro de Registro", + "tituloLivroTombo": "Libro de Ejemplar", "dataInicialColeta": "Fecha inicial de recolección:", "dataFinalColeta": "Fecha final de recolección:", diff --git a/public/locales/es/mainLayout.json b/public/locales/es/mainLayout.json index 5c7fc2a..ba3cb6a 100644 --- a/public/locales/es/mainLayout.json +++ b/public/locales/es/mainLayout.json @@ -1,5 +1,5 @@ { - "tombos": "Registros", + "tombos": "Ejemplares", "taxonomia": "Taxonomía", "reinos": "Reinos", "familias": "Familias", @@ -20,7 +20,7 @@ "coletores": "Recolectores", "herbarios": "Herbarios", "fichas": "Fichas", - "fichaTombo": "Ficha de registro", + "fichaTombo": "Ficha de ejemplar", "geolocalizacao": "Geolocalización", "mapaCompleto": "Mapa completo", "filtrosAvancados": "Filtros avanzados", @@ -31,12 +31,25 @@ "coletaColetorIntervaloData": "Recolección por recolector e intervalo de fechas", "familiasGeneros": "Familias y géneros", "locaisColeta": "Lugares de recolección", - "tombosPorCidade": "Registros por ciudad", + "tombosPorCidade": "Ejemplares por ciudad", "quantidadeFamiliaGenero": "Cantidad por familia y género", "codigoBarras": "Código de barras", "diagnosticoErrosPosicionamento": "Diagnóstico de errores de posicionamiento", "exportacao": "Exportación", "servicos": "Servicios", "erroBuscarDarwinCore": "Hubo un problema al buscar el archivo Darwin Core, inténtalo de nuevo.", - "erroRequisitarDarwinCore": "Hubo un problema al solicitar el archivo Darwin Core, inténtalo de nuevo." + "erroRequisitarDarwinCore": "Hubo un problema al solicitar el archivo Darwin Core, inténtalo de nuevo.", + "idioma": "Idioma", + "grupoColecao": "Colección", + "grupoPessoasAcesso": "Personas y acceso", + "grupoGeografia": "Geografía", + "grupoDadosOperacoes": "Datos y operaciones", + "ambienteDesenvolvimento": "Desarrollo", + "ambienteStaging": "Preproducción", + "aparencia": "Apariencia", + "aparenciaSistema": "Sistema", + "aparenciaClara": "Clara", + "aparenciaEscura": "Oscura", + "alternarMenu": "Abrir o cerrar la navegación", + "menuConta": "Menú de la cuenta" } diff --git a/public/locales/es/novaRemessaScreen.json b/public/locales/es/novaRemessaScreen.json index 76468b1..a0ab301 100644 --- a/public/locales/es/novaRemessaScreen.json +++ b/public/locales/es/novaRemessaScreen.json @@ -1,13 +1,13 @@ { "titulo": "Remesa", - "colunaTombo": "Voucher", + "colunaTombo": "Ejemplar", "colunaTipo": "Tipo", "colunaDataVencimento": "Fecha de vencimiento", "colunaAcao": "Acción", - "modalTitulo": "Agregar voucher a la remesa", - "numeroTombo": "Nº voucher:", + "modalTitulo": "Agregar ejemplar a la remesa", + "numeroTombo": "Nº de Catálogo:", "tipo": "Tipo:", "dataVencimento": "Fecha de vencimiento:", "selecioneTipo": "Seleccione un tipo", @@ -25,13 +25,13 @@ "dataEnvio": "Fecha de envío:", "observacao": "Observación:", - "adicionarTombo": "Agregar voucher", + "adicionarTombo": "Agregar ejemplar", "salvar": "Guardar", "erroBuscarRemessa": "Hubo un problema al buscar los datos de la remesa, intente nuevamente.", "erroBuscarHerbarios": "Error al buscar la lista de herbarios.", - "validacaoAdicionarTombo": "Es necesario agregar un voucher a la lista de remesa.", + "validacaoAdicionarTombo": "Es necesario agregar un ejemplar a la lista de remesa.", "tituloCadastroAlteracao": "Registro/Actualización", "sucessoCadastro": "El registro fue realizado con éxito.", @@ -41,6 +41,6 @@ "tituloEdicao": "Edición", "erroEdicao": "Hubo un problema al realizar la edición, verifique los datos e intente nuevamente.", - "confirmarExcluirTombo": "¿Está seguro de que desea eliminar este voucher de la lista?", - "descricaoExcluirTombo": "Al hacer clic en SÍ, el voucher será eliminado." + "confirmarExcluirTombo": "¿Está seguro de que desea eliminar este ejemplar de la lista?", + "descricaoExcluirTombo": "Al hacer clic en SÍ, el ejemplar será eliminado." } diff --git a/public/locales/es/relatorioCoordenadaForaPoligonoScreen.json b/public/locales/es/relatorioCoordenadaForaPoligonoScreen.json index 1773bf0..60dec52 100644 --- a/public/locales/es/relatorioCoordenadaForaPoligonoScreen.json +++ b/public/locales/es/relatorioCoordenadaForaPoligonoScreen.json @@ -8,7 +8,7 @@ "selecioneEstado": "Seleccione un estado", "cidade": "Ciudad:", "selecioneCidade": "Seleccione una ciudad", - "incluirSemCoordenadas": "Incluir vouchers sin coordenadas", + "incluirSemCoordenadas": "Incluir ejemplares sin coordenadas", "gerarPDF": "Generar PDF", @@ -24,7 +24,7 @@ "motivoForaDoPoligono": "Fuera del polígono", "nenhumRegistro": "Ningún registro encontrado.", - "tombos": "voucher(s)", + "tombos": "Ejemplar(s)", "erroBuscarPaises": "Error al buscar países.", "erroBuscarEstados": "Error al buscar estados.", diff --git a/public/locales/es/relatorioInventarioEspecies.json b/public/locales/es/relatorioInventarioEspecies.json index 6adc72a..8571ca7 100644 --- a/public/locales/es/relatorioInventarioEspecies.json +++ b/public/locales/es/relatorioInventarioEspecies.json @@ -18,7 +18,7 @@ "erroDeServidorAoExportarOPdf": "Error del servidor al exportar el PDF.", "especie": "Especie", - "tombosRelacionados": "Registros relacionados", + "tombosRelacionados": "Ejemplares relacionados", "error": "Error" } diff --git a/public/locales/es/relatorioPorPeriodo.json b/public/locales/es/relatorioPorPeriodo.json index 948410b..a136a54 100644 --- a/public/locales/es/relatorioPorPeriodo.json +++ b/public/locales/es/relatorioPorPeriodo.json @@ -1,18 +1,18 @@ { - "relatorioTombosPeriodo": "Reporte de registros por período", + "relatorioTombosPeriodo": "Reporte de ejemplares por período", "alerta": "Alerta", "erro": "Error", "selecionePeriodoValido": "Seleccione un período válido", - "dataInicioAnteriorDataFim": "La fecha de inicio debe ser anterior a la fecha de fin", + "dataInicioAnteriorDataFim": "La fecha de inicio debe ser anterior a la fecha final", "falhaBuscarDadosRelatorio": "Fallo al buscar los datos del reporte", - "dia": "día", - "semana": "semana", - "mes": "mes", - "ano": "año", + "dia": "Día", + "semana": "Semana", + "mes": "Mes", + "ano": "Año", "filtrosRelatorio": "Filtros del reporte", "periodo": "Período", @@ -34,12 +34,12 @@ "buscar": "Buscar", "carregandoDadosEstatisticos": "Cargando datos estadísticos...", - "nenhumDadoDisponivel": "Ningún dato disponible para el período seleccionado.", + "nenhumDadoDisponivel": "No hay datos disponibles para el período seleccionado.", "ajusteGranularidadeDatas": "Intente ajustar la granularidad o el rango de fechas.", - "analiseTombosDistribuicaoTemporal": "Análisis de registros - Distribución temporal", + "analiseTombosDistribuicaoTemporal": "Análisis de ejemplares - Distribución temporal", "barChartSemana": "Semana", - "tombos": "registros", + "tombos": "ejemplares", "amount": "Cantidad", - "quantidadeTombos": "Cantidad de registros" + "quantidadeTombos": "Cantidad de ejemplares" } diff --git a/public/locales/es/relatorioTombosPorCidadeScreen.json b/public/locales/es/relatorioTombosPorCidadeScreen.json index 0536de8..e3a0597 100644 --- a/public/locales/es/relatorioTombosPorCidadeScreen.json +++ b/public/locales/es/relatorioTombosPorCidadeScreen.json @@ -1,5 +1,5 @@ { - "titulo": "Informe de Vouchers por Ciudad", + "titulo": "Informe de ejemplares por Ciudad", "filtros": "Filtros del informe", "pais": "País:", diff --git a/public/locales/es/rfid.json b/public/locales/es/rfid.json index 1650ca3..530094c 100644 --- a/public/locales/es/rfid.json +++ b/public/locales/es/rfid.json @@ -20,16 +20,16 @@ "search": "Buscar", "update": "Actualizar", "all": "Todos", - "failure": "Falla", + "failure": "Fallo", "error": "Error", - "warning": "Aviso", + "warning": "Advertencia", "attention": "Atención", "cancel": "Cancelar", "saveUrl": "Guardar URL" }, "menu": { "configuration": "Configuración", - "conference": "Conferencia", + "conference": "Conclusión / Cotejo", "link": "Vinculación", "inventory": "Inventario" }, @@ -43,49 +43,49 @@ "connectHelp": "* Ajuste los parámetros anteriores y haga clic en conectar para habilitar la pantalla." }, "config": { - "title": "Configuración de Dispositivo RFID", + "title": "Configuración del Dispositivo RFID", "cardTitle": "Configuración", "serviceAddress": "Dirección del Servicio:", "deviceLabel": "Dispositivo:", "identifierLabel": "Identificador:", - "serialPortLabel": "Puerto Serial (Connection):", + "serialPortLabel": "Puerto Serial (Conexión):", "serialPortPlaceholder": "Ej: COM1", "scanPortsAgain": "Escanear puertos nuevamente", - "hardwareReady": "Hardware Listo", - "hardwareInitialized": "Hardware Inicializado", + "hardwareReady": "Hardware listo", + "hardwareInitialized": "Hardware inicializado", "initializeAndConnect": "Inicializar y Conectar", "diagnostics": "Diagnóstico", "testingCommunication": "Probando comunicación...", - "serviceInaccessible": "Servicio Inaccesible", + "serviceInaccessible": "Servicio inaccesible", "serviceCommunicationError": "No fue posible comunicarse con la API del dispositivo RFID.", "check": "Verifique:", "checkExecutableOpen": "Si el ejecutable local está abierto.", "checkServiceAddress": "Si la dirección del servicio es correcta.", - "operatingSystem": "Sistema Operativo:", - "uptime": "Tiempo de Actividad (Uptime):", - "localSystem": "Sistema Local", - "uptimeUnavailable": "Uptime no disponible", - "advancedConfig": "Configuración Avanzada", - "communicationWarning": "Aviso de Comunicación", - "urlWarningDescription": "Cambie la URL de la API solo si conoce el puerto configurado en la máquina anfitriona.", - "localServiceAddress": "Dirección del Servicio Local:", - "readerStatus": "Estado del Lector:" + "operatingSystem": "Sistema operativo:", + "uptime": "Tiempo de actividad (Uptime):", + "localSystem": "Sistema local", + "uptimeUnavailable": "Tiempo de actividad no disponible", + "advancedConfig": "Configuración avanzada", + "communicationWarning": "Aviso de comunicación", + "urlWarningDescription": "Cambie la URL de la API solo si conoce el puerto configurado en el equipo anfitrión.", + "localServiceAddress": "Dirección del servicio local:", + "readerStatus": "Estado del lector:" }, "conference": { - "title": "Conferencia RFID", - "filtersTitle": "Filtros de la Conferencia", + "title": "Cotejo RFID", + "filtersTitle": "Filtros de cotejo", "id": "ID", - "tombo": "Tombo", - "barcode": "Código de Barras", + "tombo": "Ejemplar", + "barcode": "Código de barras", "createdAt": "Fecha de creación", "updatedAt": "Fecha de última actualización", "status": "Estado", - "statusReading": "Estado de la Lectura", - "searchTombo": "Buscar por Tombo...", - "searchBarcode": "Buscar por Cód. Barras...", + "statusReading": "Estado de la lectura", + "searchTombo": "Buscar por ejemplar...", + "searchBarcode": "Buscar por cód. de barras...", "searchEpc": "Buscar por EPC...", - "completed": "Concluido", - "failed": "Falla", + "completed": "Completado", + "failed": "Fallo", "totalRecords": "{{start}}-{{end}} de {{total}} registros" }, "inventory": { @@ -95,80 +95,80 @@ "identified": "Identificado", "unknown": "Desconocido", "notIdentified": "No identificado", - "tombo": "Tombo", - "collector": "Colector Principal", - "totalReads": "Total lecturas", + "tombo": "Ejemplar", + "collector": "Colector principal", + "totalReads": "Total de lecturas", "actions": "Acciones", "removeQuestion": "¿Eliminar?", - "totalTagsRead": "Total Tags Leídas", + "totalTagsRead": "Total de etiquetas leídas", "connectToStart": "Conecte para iniciar.", "checking": "Verificando...", - "pauseInventory": "Pausar Inventario", - "startInventory": "Iniciar Inventario", + "pauseInventory": "Pausar inventario", + "startInventory": "Iniciar inventario", "clearReadsQuestion": "¿Limpiar lecturas?", - "clearList": "Limpiar Lista", - "foundPhotos": "Fotos Encontradas", + "clearList": "Limpiar lista", + "foundPhotos": "Fotos encontradas", "activelyReading": "Leyendo activamente...", - "emptyTags": "Ninguna tag." + "emptyTags": "Ninguna etiqueta." }, "link": { "title": "Vinculación RFID", "selectPhoto": "Seleccionar foto", - "searchPlaceholder": "Buscar por tombo/código de barras", - "noTomboFound": "Ningún tombo encontrado", + "searchPlaceholder": "Buscar por ejemplar/código de barras", + "noTomboFound": "Ningún ejemplar encontrado", "confirmation": "Confirmación", "details": "Detalles", "barcode": "Código de barras", "scientificName": "Nombre científico", "mainCollector": "Colector principal", "photo": "Foto", - "photoAlt": "Foto del ítem", - "checkingHardware": "Verificando Hardware...", - "startWrite": "Iniciar Grabación", + "photoAlt": "Foto del elemento", + "checkingHardware": "Verificando hardware...", + "startWrite": "Iniciar grabación", "noLinkInfo": "Sin información para vinculación." }, "notifications": { "urlRequiredTitle": "URL obligatoria", - "urlRequiredDescription": "Informe la dirección de la API local RFID.", + "urlRequiredDescription": "Ingrese la dirección de la API local RFID.", "readerReadyTitle": "Lector listo", - "readerReadyDescription": "El dispositivo RFID fue inicializado con éxito.", - "connectionFailureTitle": "Falla en la conexión", + "readerReadyDescription": "El dispositivo RFID se inicializó con éxito.", + "connectionFailureTitle": "Fallo en la conexión", "connectionFailureDescription": "No fue posible inicializar el lector RFID.", - "noPortTitle": "Ningún puerto", - "noPortDescription": "No se encontraron puertos seriales disponibles en la máquina.", - "scanFailedTitle": "Escaneo falló", - "scanFailedDescription": "No fue posible buscar los puertos seriales de la máquina local.", + "noPortTitle": "Sin puertos", + "noPortDescription": "No se encontraron puertos seriales disponibles en el equipo.", + "scanFailedTitle": "Escaneo fallido", + "scanFailedDescription": "No fue posible buscar los puertos seriales del equipo local.", "alreadyConnected": "El dispositivo ya está conectado y listo para usar.", - "serialPortTitle": "Puerto Serial", + "serialPortTitle": "Puerto serial", "serialPortRequired": "Seleccione el puerto serial para comunicarse con Hexapad.", "deviceConnected": "Dispositivo ({{device}}) inicializado con éxito.", - "connectionErrorTitle": "Error de Conexión", - "hardwareInitFailed": "Falla al intentar inicializar el hardware. Verifique el servicio.", + "connectionErrorTitle": "Error de conexión", + "hardwareInitFailed": "Fallo al intentar inicializar el hardware. Verifique el servicio.", "emptyAddress": "La dirección no puede estar vacía.", "addressUpdatedTitle": "Dirección actualizada", - "addressUpdatedDescription": "La dirección del servicio local fue alterada.", - "conferenceFetchFailure": "Falla al buscar los datos de la conferencia.", - "serviceUnavailableTitle": "Servicio indisponible", + "addressUpdatedDescription": "La dirección del servicio local fue modificada.", + "conferenceFetchFailure": "Fallo al buscar los datos de cotejo.", + "serviceUnavailableTitle": "Servicio no disponible", "serviceUnavailableDescription": "El servicio RFID no está respondiendo.", - "readingStartedTitle": "Lectura Iniciada", + "readingStartedTitle": "Lectura iniciada", "readingStartedDescription": "Acerque las etiquetas al lector.", - "connectionLostTitle": "Conexión Perdida", + "connectionLostTitle": "Conexión perdida", "communicationStopped": "El servicio de comunicación RFID se detuvo.", - "tombosFetchFailure": "Falla al buscar los tombos para vinculación.", - "deviceUnavailableTitle": "Dispositivo indisponible", + "tombosFetchFailure": "Fallo al buscar los ejemplares para vinculación.", + "deviceUnavailableTitle": "Dispositivo no disponible", "configureDeviceBeforeWrite": "Configure el dispositivo antes de grabar.", "checkingReaderCommunication": "Verificando comunicación con el lector RFID...", - "deviceOffline": "Dispositivo offline", - "communicationLostTitle": "Comunicación Perdida", + "deviceOffline": "Dispositivo fuera de línea", + "communicationLostTitle": "Comunicación perdida", "rfidServiceStopped": "El servicio RFID dejó de responder.", "requestingEpc": "Solicitando EPC al servidor...", - "serverFailure": "Falla en el servidor.", + "serverFailure": "Fallo en el servidor.", "writingRfid": "Grabando RFID...", "syncingServer": "Sincronizando con el servidor...", - "writeCompletedTitle": "Grabación Concluida!", + "writeCompletedTitle": "¡Grabación concluida!", "rfidLinked": "RFID vinculado a {{barcode}}.", - "physicalWriteFailure": "No fue posible grabar en la tag física.", - "operationAbortedTitle": "Operación Abortada", + "physicalWriteFailure": "No fue posible grabar en la etiqueta física.", + "operationAbortedTitle": "Operación abortada", "linkError": "Ocurrió un error durante la vinculación." } } diff --git a/public/locales/es/simpleTableComponent.json b/public/locales/es/simpleTableComponent.json index 6157515..12a9e3c 100644 --- a/public/locales/es/simpleTableComponent.json +++ b/public/locales/es/simpleTableComponent.json @@ -14,5 +14,8 @@ "ordenacaoDecrescente": "Haga clic para ordenar de forma descendente", "ordenacaoCrescente": "Haga clic para ordenar de forma ascendente", - "cancelarOrdenacao": "Cancelar ordenación" + "cancelarOrdenacao": "Cancelar ordenación", + "intervaloTotal": "{{from}}–{{to}} de {{total}}", + "colunas": "Columnas", + "restaurarColunas": "Restaurar predeterminado" } diff --git a/public/locales/es/tableColetaPorLocalData.json b/public/locales/es/tableColetaPorLocalData.json index f89dc84..c7121f0 100644 --- a/public/locales/es/tableColetaPorLocalData.json +++ b/public/locales/es/tableColetaPorLocalData.json @@ -3,5 +3,5 @@ "colFamilia": "Familia", "colEspecie": "Especie", "colAutor": "Autor", - "colTombo": "Registro" + "colTombo": "Ejemplar" } diff --git a/public/locales/es/tombo.json b/public/locales/es/tombo.json index b928ee3..f3b44f3 100644 --- a/public/locales/es/tombo.json +++ b/public/locales/es/tombo.json @@ -5,8 +5,8 @@ "noneFound": "No se encontraron resultados", "noneFoundEntity": "No se encontró {{entity}}", "authorLabel": "Autor:", - "numberTombo": "Número de accesión:", - "tomboDate": "Fecha de accesión:", + "numberTombo": "Número de catálogo:", + "tomboDate": "Fecha de ingreso:", "kingdom": "Reino:", "family": "Familia:", "subfamily": "Subfamilia:", @@ -30,18 +30,18 @@ "soil": "Suelo:", "relief": "Relieve:", "vegetation": "Vegetación:", - "successionalStage": "Fase sucesional:", + "successionalStage": "Etapa sucesional:", "latitude": "Latitud:", "longitude": "Longitud:", "altitude": "Altitud:", - "popularName": "Nombre popular:", - "type": "Tipo:", - "exsicataType": "Tipo de exsicata:", + "popularName": "Nombre común:", + "type": "Tipo:", + "exsicataType": "Tipo de exsiccata:", "collectionNumber": "Número de colecta:", "collectionDate": "Fecha de colecta:", - "tomboObservations": "Observaciones de la accesión:", - "editTombo": "Editar datos de la accesión", - "tomboTitle": "Accesión", + "tomboObservations": "Observaciones del ejemplar:", + "editTombo": "Editar datos del ejemplar", + "tomboTitle": "Ejemplar", "editTitle": "Edición", "registerTitle": "Registro", "cancelExit": "Salir/Cancelar", @@ -53,7 +53,7 @@ "searchSpecies": "Escriba para buscar especies...", "searchSubspecies": "Escriba para buscar subespecies...", "searchVarieties": "Escriba para buscar variedades...", - "searchStages": "Escriba para buscar fases...", + "searchStages": "Escriba para buscar etapas...", "searchCollectors": "Seleccione los colectores", "searchIdentifiers": "Escriba para buscar identificadores...", "searchSites": "Busque el sitio de colecta", @@ -74,63 +74,63 @@ "popularNameExample": "Maracuyá dulce", "name": "Nombre:", "email": "Correo electrónico:", - "collectorNumberShort": "N.º colector:", - "informNameForRegister": "Informe el nombre{{suffix}}:", - "informAuthorName": "Informe el nombre del autor:", + "collectorNumberShort": "N.º Colector:", + "informNameForRegister": "Ingrese el nombre{{suffix}}:", + "informAuthorName": "Ingrese el nombre del autor:", "yearFuture": "El año no puede estar en el futuro.", - "invalidDateFormat": "Formato inválido. Use DD/MM/AAAA.", - "invalidMonth": "Mes inválido.", - "invalidDay": "Día inválido.", - "invalidDate": "Fecha inválida.", - "pendingTitle": "Pendencia", - "pendingContent": "Esta accesión tiene pendencias no resueltas. ¿Desea continuar editando?", + "invalidDateFormat": "Formato no válido. Use DD/MM/AAAA.", + "invalidMonth": "Mes no válido.", + "invalidDay": "Día no válido.", + "invalidDate": "Fecha no válida.", + "pendingTitle": "Pendiente", + "pendingContent": "Este ejemplar tiene pendientes no resueltas. ¿Desea continuar editando?", "deletePhotoConfirmTitle": "¿Está seguro de que desea eliminar esta foto?", - "deletePhotoConfirmContent": "Si hace clic en SÍ, la foto será eliminada.", + "deletePhotoConfirmContent": "Al hacer clic en SÍ, la foto será eliminada.", "fillRequiredFields": "Complete todos los campos obligatorios.", - "informNameToRegister": "Informe el nombre para el registro.", - "invalidTombo": "Accesión (HCF) inválida.", - "selectCityFirst": "Seleccione una ciudad primero.", + "informNameToRegister": "Ingrese el nombre para el registro.", + "invalidTombo": "Ejemplar (HCF) no válido.", + "selectCityFirst": "Por favor, seleccione una ciudad primero.", "fillCollectorNameNumber": "El nombre y el número del colector son obligatorios.", "fillCollectionSiteDescription": "La descripción del sitio de colecta es obligatoria.", "barcodeUpdated": "Los códigos de barras se actualizaron correctamente.", "barcodeUpdateError": "Error al actualizar los códigos de barras. Inténtelo nuevamente.", "barcodeCreated": "El código se creó correctamente.", "barcodeCreateError": "Hubo un problema al crear el código. Inténtelo nuevamente.", - "photoUpdated": "La foto se actualizó correctamente.", - "photoUpdateError": "Hubo un problema al actualizar la foto. Inténtelo nuevamente.", + "photoUpdated": "La foto se cambió correctamente.", + "photoUpdateError": "Hubo un problema al cambiar la foto. Inténtelo nuevamente.", "barcodeDeleted": "El código de barras se eliminó correctamente.", "barcodeDeleteError": "Hubo un problema al eliminar el código de barras. Inténtelo nuevamente.", - "tomboLoadError": "Error al cargar los datos de la accesión: {{message}}", - "tomboEditError": "Hubo un problema al editar los datos de la accesión. Inténtelo nuevamente.", - "tomboCreateError": "Hubo un problema al registrar la nueva accesión. Inténtelo nuevamente.", - "coordinateOutOfCity": "La coordenada informada no pertenece a la ciudad seleccionada y no se encontró otra ciudad correspondiente.", - "coordinateOtherCity": "La coordenada informada no pertenece a la ciudad seleccionada. Pertenece a {{city}}{{state}}.", - "coordinateOfTomboOtherCity": "La coordenada informada no pertenece a la ciudad de la accesión. Pertenece a {{city}}{{state}}.", - "coordinateOfTomboUnknownCity": "La coordenada informada no pertenece a la ciudad de la accesión y no se encontró otra ciudad correspondiente.", - "detailEditTombo": "Editar accesión", + "tomboLoadError": "Error al cargar los datos del ejemplar: {{message}}", + "tomboEditError": "Hubo un problema al editar los datos del ejemplar. Inténtelo nuevamente.", + "tomboCreateError": "Hubo un problema al registrar el nuevo ejemplar. Inténtelo nuevamente.", + "coordinateOutOfCity": "La coordenada ingresada no pertenece a la ciudad seleccionada y no se encontró otra ciudad correspondiente.", + "coordinateOtherCity": "La coordenada ingresada no pertenece a la ciudad seleccionada. Pertenece a {{city}}{{state}}.", + "coordinateOfTomboOtherCity": "La coordenada ingresada no pertenece a la ciudad del ejemplar. Pertenece a {{city}}{{state}}.", + "coordinateOfTomboUnknownCity": "La coordenada ingresada no pertenece a la ciudad del ejemplar y no se encontró otra ciudad correspondiente.", + "detailEditTombo": "Editar ejemplar", "detailScientificName": "Nombre científico:", - "detailAuthorSpecies": "Autor de especie:", - "detailAuthorSubspecies": "Autor de subespecie:", - "detailAuthorVariety": "Autor de variedad:", + "detailAuthorSpecies": "Autor de la especie:", + "detailAuthorSubspecies": "Autor de la subespecie:", + "detailAuthorVariety": "Autor de la variedad:", "detailCollectionSite": "Sitio de colecta:", "detailDescription": "Descripción:", "detailObservations": "Observaciones:", "detailAnnexCollectionObservations": "Observaciones de la colección anexa:", "detailLatitudeDatum": "Latitud: (datum WGS84)", "detailLongitudeDatum": "Longitud: (datum WGS84)", - "detailLoadTomboError": "Hubo un problema al buscar los datos de la accesión. Inténtelo nuevamente.", + "detailLoadTomboError": "Hubo un problema al buscar los datos del ejemplar. Inténtelo nuevamente.", "detailLoadKingdomsError": "Error al buscar reinos.", "detailCoordinateWarningTitle": "Atención", - "listTitle": "Accesiones", - "searchTombo": "Buscar accesión", - "searchTombos": "Buscar accesiones", - "searchTombosError": "Error al buscar las accesiones.", - "searchTombosFailure": "Error al buscar accesiones.", - "deleteTomboTitle": "Eliminar accesión", - "deleteTomboSuccess": "La accesión se eliminó correctamente.", - "deleteTomboError": "Hubo un problema al eliminar la accesión. Inténtelo nuevamente.", - "deleteTomboConfirmTitle": "¿Está seguro de que desea eliminar esta accesión?", - "deleteTomboConfirmContent": "Si hace clic en SÍ, la accesión será eliminada.", + "listTitle": "Ejemplares", + "searchTombo": "Buscar ejemplar", + "searchTombos": "Buscar ejemplares", + "searchTombosError": "Error al buscar los ejemplares.", + "searchTombosFailure": "Error al buscar ejemplares.", + "deleteTomboTitle": "Eliminar ejemplar", + "deleteTomboSuccess": "El ejemplar se eliminó correctamente.", + "deleteTomboError": "Hubo un problema al eliminar el ejemplar. Inténtelo nuevamente.", + "deleteTomboConfirmTitle": "¿Está seguro de que desea eliminar este ejemplar?", + "deleteTomboConfirmContent": "Al hacer clic en SÍ, el ejemplar será eliminado.", "status": "Estado:", "select": "Seleccione", "regular": "Regular", @@ -145,33 +145,33 @@ "popularNamePlaceholder": "Maracuyá", "clear": "Limpiar", "tableHcf": "HCF", - "tablePopularName": "Nombre popular", + "tablePopularName": "Nombre común", "tableScientificName": "Nombre científico", "tableCollectionDate": "Fecha de colecta", "tableCollector": "Colector", "tableAction": "Acción", "exportSelectFields": "Seleccione los campos a exportar:", - "exportMinMaxColumns": "Seleccione al menos 1 y como máximo 5 columnas para exportar.", - "exportProvideFilter": "Informe un período, accesiones específicas o un rango de fechas para exportar", - "exportOnlyOneGroup": "La exportación de datos debe hacerse por un período, accesiones específicas o rango de fechas, nunca usando dos o más opciones", + "exportMinMaxColumns": "Seleccione un mínimo de 1 y un máximo de 5 columnas para exportar.", + "exportProvideFilter": "Ingrese un período, ejemplares específicos o un rango de fechas para exportar.", + "exportOnlyOneGroup": "La exportación de datos debe hacerse por un período, ejemplares específicos o rango de fechas, nunca utilizando dos o más opciones.", "alert": "Alerta", - "exportCollectionDate": "Fecha de Colecta", - "sequenceTombo": "Secuencia de la accesión", + "exportCollectionDate": "Fecha de colecta", + "sequenceTombo": "Secuencia del ejemplar", "barcode": "Código de barras", "collectionNumberShort": "N.º Colecta", "from": "Desde:", "until": "Hasta:", "or": "O", - "exportSpecificTombos": "Números de las accesiones a exportar:", - "fromTomboDate": "Desde la fecha de accesión:", - "untilTomboDate": "Hasta la fecha de accesión:", - "selectInitialTomboDate": "Seleccione la fecha inicial de accesión", - "selectFinalTomboDate": "Seleccione la fecha final de accesión", + "exportSpecificTombos": "Números de los ejemplares a exportar:", + "fromTomboDate": "Desde la fecha de ingreso:", + "untilTomboDate": "Hasta la fecha de ingreso:", + "selectInitialTomboDate": "Seleccione la fecha inicial de ingreso", + "selectFinalTomboDate": "Seleccione la fecha final de ingreso", "addFilter": "Agregar filtro", "remove": "Eliminar", "identifierFilter": "Identificador", "collectorFilter": "Colector", - "collectionNumberFilter": "Número de Colecta", + "collectionNumberFilter": "Número de colecta", "identifierPlaceholder": "Ingrese el nombre del identificador", "collectorPlaceholder": "Ingrese el nombre del colector", "selectCollectorFirst": "Seleccione un colector primero", @@ -181,23 +181,23 @@ "attachedImage": "Imagen adjunta", "attachImageToCode": "Adjuntar imagen al código", "photo": "Foto", - "editImage": "editar imagen", - "attachedCollectionsCountByCollector": "Cantidad de colecta por colector", - "printSheetWithCode": "Imprimir ficha de accesión (con código de barras)", + "editImage": "Editar imagen", + "attachedCollectionsCountByCollector": "Cantidad de colectas por colector", + "printSheetWithCode": "Imprimir ficha de ejemplar (con código de barras)", "printSheetWithCodeTitle": "Imprimir ficha con código de barras", - "printSheetWithoutCode": "Imprimir ficha de accesión (sin código de barras)", + "printSheetWithoutCode": "Imprimir ficha de ejemplar (sin código de barras)", "printSheetWithoutCodeTitle": "Imprimir ficha sin código de barras", - "printReducedSheet": "Imprimir ficha reducida de accesión", + "printReducedSheet": "Imprimir ficha reducida de ejemplar", "printReducedSheetTitle": "Imprimir ficha reducida", "loadBarcodesError": "No fue posible cargar los códigos de barras.", "printStarted": "¡Impresión iniciada!", "printStartError": "No fue posible iniciar la impresión.", - "tomboLabel": "Accesión:", + "tomboLabel": "Ejemplar:", "copiesQuantity": "Cantidad de copias", - "informCopies": "Informe la cantidad de copias", + "informCopies": "Ingrese la cantidad de copias", "allow1to3": "Permitido entre 1 y 3", "loadingBarcodes": "Cargando códigos de barras...", - "noBarcodeAvailableForTombo": "No hay código de barras disponible para esta accesión.", + "noBarcodeAvailableForTombo": "No hay código de barras disponible para este ejemplar.", "selectBarcode": "Código de barras", "selectBarcodeRequired": "Seleccione un código de barras", "selectCode": "Seleccione el código", @@ -207,9 +207,9 @@ "codePermanentlyLost": "¡El código se perderá permanentemente!", "deleteCodeConfirm": "Sí, quiero eliminarlo", "codeRemoved": "Código eliminado: {{code}}", - "noLastBarcodeSuggest": "La última accesión registrada no tiene código registrado. Se sugiere la secuencia a partir de: 41800", + "noLastBarcodeSuggest": "El último ejemplar registrado no tiene código registrado. Sugiriendo secuencia a partir de: 41800", "prepareCodeGenerationError": "Error al preparar la generación del código.", - "validNewCode": "Informe un nuevo código válido (número o HCF completo).", + "validNewCode": "Ingrese un nuevo código válido (número o HCF completo).", "familyRequiredForSubfamily": "Seleccione una familia para registrar una subfamilia.", "genusRequiredForSpecies": "Seleccione un género para registrar una especie.", "familyRequiredForSpecies": "Seleccione una familia para registrar una especie.", diff --git a/public/locales/pt-BR/common.json b/public/locales/pt-BR/common.json index cf22765..8c4cae1 100644 --- a/public/locales/pt-BR/common.json +++ b/public/locales/pt-BR/common.json @@ -18,7 +18,7 @@ "atualizacaoRealizadaSucesso": "A atualização foi realizada com sucesso.", "notificacaoExcluir": "Excluir", - + "tituloFalha": "Falha", "erro": "Erro", "error": "Error", @@ -29,6 +29,9 @@ "nenhumResultadoEncontrado": "Nenhum resultado encontrado", "sim": "SIM", - "nao": "NÃO" + "nao": "NÃO", + + "page": { + "title": "{{title}} - Herbário UTFPR Campo Mourão" + } } - \ No newline at end of file diff --git a/public/locales/pt-BR/listaUsuariosPage.json b/public/locales/pt-BR/listaUsuariosPage.json new file mode 100644 index 0000000..f6afce1 --- /dev/null +++ b/public/locales/pt-BR/listaUsuariosPage.json @@ -0,0 +1,74 @@ +{ + "title": "Usuários", + "description": "Gerencie usuários e o acesso ao herbário.", + + "search": { + "placeholder": "Buscar por nome, e-mail ou telefone" + }, + + "filters": { + "role": "Função", + "allRoles": "Todas as funções", + "trigger": "Filtros" + }, + + "columns": { + "title": "Colunas", + "trigger": "Colunas", + "reset": "Restaurar padrão", + "name": "Nome", + "type": "Tipo", + "email": "E-mail", + "phone": "Telefone", + "creationDate": "Data de criação", + "actions": "Ações" + }, + + "roles": { + "curator": "Curador", + "operator": "Operador", + "identifier": "Identificador" + }, + + "actions": { + "add": "Adicionar usuário", + "addShort": "Adicionar", + "edit": "Editar", + "delete": "Excluir", + "menu": "Ações da linha" + }, + + "create": { + "title": "Adicionar usuário", + "submit": "Criar usuário", + "success": "O usuário foi criado com sucesso.", + "errorTitle": "Não foi possível criar o usuário", + "error": "Ocorreu um problema ao criar o usuário. Tente novamente." + }, + + "delete": { + "title": "Excluir usuário?", + "description": "Tem certeza de que deseja excluir permanentemente “{{name}}”? Esta ação não pode ser desfeita.", + "success": "O usuário foi excluído com sucesso.", + "errorTitle": "Não foi possível excluir o usuário", + "error": "Ocorreu um problema ao excluir o usuário. Tente novamente." + }, + + "empty": { + "title": "Ainda não há usuários", + "description": "Adicione o primeiro usuário ao herbário.", + "filteredTitle": "Nenhum usuário encontrado", + "filteredDescription": "Tente alterar a busca ou os filtros.", + "clearFilters": "Limpar filtros" + }, + + "error": { + "title": "Não foi possível carregar os usuários.", + "description": "Ocorreu um problema ao buscar a lista de usuários.", + "retry": "Tentar novamente" + }, + + "pagination": { + "total": "{{from}}–{{to}} de {{total}} usuários" + } +} diff --git a/public/locales/pt-BR/mainLayout.json b/public/locales/pt-BR/mainLayout.json index 4752e46..4da48f8 100644 --- a/public/locales/pt-BR/mainLayout.json +++ b/public/locales/pt-BR/mainLayout.json @@ -39,5 +39,17 @@ "servicos": "Serviços", "erroBuscarDarwinCore": "Houve um problema ao buscar o arquivo Darwin Core, tente novamente.", "erroRequisitarDarwinCore": "Houve um problema ao requisitar o arquivo Darwin Core, tente novamente.", - "idioma": "Idioma" + "idioma": "Idioma", + "grupoColecao": "Coleção", + "grupoPessoasAcesso": "Pessoas e acesso", + "grupoGeografia": "Geografia", + "grupoDadosOperacoes": "Dados e operações", + "ambienteDesenvolvimento": "Desenvolvimento", + "ambienteStaging": "Homologação", + "aparencia": "Aparência", + "aparenciaSistema": "Sistema", + "aparenciaClara": "Clara", + "aparenciaEscura": "Escura", + "alternarMenu": "Abrir ou fechar a navegação", + "menuConta": "Menu da conta" } diff --git a/public/locales/pt-BR/simpleTableComponent.json b/public/locales/pt-BR/simpleTableComponent.json index 5e03d17..8d27283 100644 --- a/public/locales/pt-BR/simpleTableComponent.json +++ b/public/locales/pt-BR/simpleTableComponent.json @@ -14,5 +14,8 @@ "ordenacaoDecrescente": "Clique para ordenar decrescente", "ordenacaoCrescente": "Clique para ordenar crescente", - "cancelarOrdenacao": "Cancelar ordenação" + "cancelarOrdenacao": "Cancelar ordenação", + "intervaloTotal": "{{from}}–{{to}} de {{total}}", + "colunas": "Colunas", + "restaurarColunas": "Restaurar padrão" } diff --git a/src/App.tsx b/src/App.tsx index 7ed8237..86e52f8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,11 @@ -import { Navigate, Route, Routes } from 'react-router' +import { + Navigate, + Route, + Routes +} from 'react-router' import { useAuth } from './contexts/Auth/useAuth' -import ListaUsuariosPage from './features/usuarios/ListaUsuariosPage' +import ListaUsuariosPage from './features/usuarios/UsuariosListPage' import { isCuradorOuOperador, isCuradorOuOperadorOuIdentificador @@ -153,10 +157,10 @@ export function AppRoutes() { path="relatorio-quantidade-familia-generos" element={guard(Boolean(auth.user?.id), RelatorioQuantidadeScreen)} /> - - - - + + + + } /> +
+ + {title} + + {description && ( + + {description} + + )} +
+ {extra} + + ) +} diff --git a/src/components/Page/Page.tsx b/src/components/Page/Page.tsx new file mode 100644 index 0000000..460a73d --- /dev/null +++ b/src/components/Page/Page.tsx @@ -0,0 +1,40 @@ +import { useEffect, type ReactNode } from 'react' + +import { useTranslation } from 'react-i18next' + +import { Header } from './Header' + +export interface PageProps { + title: string + description?: string + extra?: ReactNode + children?: ReactNode +} + +export function Page({ + title, + description, + extra, + children +}: PageProps) { + const { t } = useTranslation() + + useEffect(() => { + const previousTitle = document.title + document.title = t('page.title', { title }) + return () => { + document.title = previousTitle + } + }, [t, title]) + + return ( +
+
+ {children} +
+ ) +} diff --git a/src/components/estados/Estados.component.jsx b/src/components/estados/Estados.component.jsx index 1a79246..18e7b50 100644 --- a/src/components/estados/Estados.component.jsx +++ b/src/components/estados/Estados.component.jsx @@ -3,13 +3,12 @@ import React from 'react' import { Divider, Card, Row, Col, Input, Button, notification, Form, Select } from 'antd' +import { withTranslation } from 'react-i18next' import ModalCadastroComponent from '@/components/ModalCadastroComponent' import SimpleTableComponent from '@/components/SimpleTableComponent' import TotalRecordFound from '@/components/TotalRecordsFound' import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons' -import { withTranslation } from 'react-i18next' - const ListaEstadosComponent = ({ t, @@ -114,7 +113,7 @@ const ListaEstadosComponent = ({
- {t('estadoComponent:buscarNomeEstado')}: + {t('estadoComponent:buscarNomeEstado')} @@ -122,7 +121,7 @@ const ListaEstadosComponent = ({ - {t('estadoComponent:buscarPais')}: + {t('estadoComponent:buscarPais')} + + + + + + + + + + + + + + + + ) +} diff --git a/src/features/usuarios/redesign/components/UsersToolbar.tsx b/src/features/usuarios/redesign/components/UsersToolbar.tsx new file mode 100644 index 0000000..04bbc43 --- /dev/null +++ b/src/features/usuarios/redesign/components/UsersToolbar.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react' + +import { + Button, Flex, Grid, Input, Popover, Select +} from 'antd6' +import { useTranslation } from 'react-i18next' + +import { FilterOutlined } from '@ant-design/icons' + +interface UsersToolbarProps { + query: string + role: string + onSearch: (query: string) => void + onRoleChange: (role: string) => void +} + +export function UsersToolbar({ + query, + role, + onSearch, + onRoleChange +}: UsersToolbarProps) { + const { t } = useTranslation('listaUsuariosPage') + const screens = Grid.useBreakpoint() + const isMobile = screens.md === false + const isNarrow = screens.sm === false + const [draft, setDraft] = useState(query) + + useEffect(() => { + setDraft(query) + }, [query]) + + const roleOptions = [ + { value: '1', label: t('roles.curator') }, + { value: '2', label: t('roles.operator') }, + { value: '3', label: t('roles.identifier') } + ] + + const roleSelect = ( + { + void i18n.changeLanguage(value) + }} + style={{ width: isMobile ? 88 : 132 }} + aria-label={t('mainLayout:idioma')} + options={[ + { value: 'pt-BR', label: t('tombo:languagePortuguese') }, + { value: 'en', label: t('tombo:languageEnglish') }, + { value: 'es', label: t('tombo:languageSpanish') } + ]} + /> + + + + + ) + : ( + + )} + + + ) +} diff --git a/src/layouts/components/AppSidebar.tsx b/src/layouts/components/AppSidebar.tsx new file mode 100644 index 0000000..717e020 --- /dev/null +++ b/src/layouts/components/AppSidebar.tsx @@ -0,0 +1,70 @@ +import { useEffect, useMemo, useState } from 'react' + +import { Menu } from 'antd6' +import { useTranslation } from 'react-i18next' +import { useLocation } from 'react-router' + +import { useAuth } from '@/contexts/Auth/useAuth' +import { isCuradorOuOperador } from '@/helpers/usuarios' + +import { + buildNavItems, + getOpenMenuKeys, + getSelectedMenuKey +} from '../navItems' +import { AppSiderLogo } from './AppSiderLogo' + +interface AppSidebarProps { + collapsed: boolean + onNavigate?: () => void +} + +export function AppSidebar({ collapsed, onNavigate }: AppSidebarProps) { + const { t } = useTranslation() + const auth = useAuth() + const location = useLocation() + const selectedKey = getSelectedMenuKey(location.pathname) + const [openKeys, setOpenKeys] = useState(() => getOpenMenuKeys(location.pathname)) + + useEffect(() => { + const requiredOpenKeys = getOpenMenuKeys(location.pathname) + setOpenKeys(current => Array.from(new Set([...current, ...requiredOpenKeys]))) + }, [location.pathname]) + + const items = useMemo( + () => buildNavItems({ + t, + auth, + isCuradorOuOperador: isCuradorOuOperador() + }), + [t, auth] + ) + + return ( +
+ +
+ setOpenKeys(keys)} + onClick={onNavigate} + items={items} + style={{ + borderInlineEnd: 'none', + background: 'transparent' + }} + /> +
+
+ ) +} diff --git a/src/layouts/components/AppSiderLogo.tsx b/src/layouts/components/AppSiderLogo.tsx new file mode 100644 index 0000000..39248e7 --- /dev/null +++ b/src/layouts/components/AppSiderLogo.tsx @@ -0,0 +1,34 @@ +import { Link } from 'react-router' + +import logoColorida from '@/assets/img/logo_colorida.png' +import logoSimples from '@/assets/img/logo-simples-hcf.png' + +interface AppSiderLogoProps { + collapsed: boolean +} + +export function AppSiderLogo({ collapsed }: AppSiderLogoProps) { + return ( + + UTFPR HCF + + ) +} diff --git a/src/layouts/navItems.tsx b/src/layouts/navItems.tsx new file mode 100644 index 0000000..55d52b4 --- /dev/null +++ b/src/layouts/navItems.tsx @@ -0,0 +1,504 @@ +import type { TFunction } from 'i18next' +import { Link } from 'react-router' + +import type { AuthContextValue } from '@/contexts/Auth/AuthContext' +import { + BankOutlined, + BarsOutlined, + DatabaseOutlined, + DesktopOutlined, + EnvironmentOutlined, + FileTextOutlined, + FlagOutlined, + ScanOutlined, + SearchOutlined, + SnippetsOutlined, + TeamOutlined +} from '@ant-design/icons' + +import type { MenuProps } from 'antd6' + +type MenuItem = NonNullable[number] + +const TAXONOMIA_RESOURCES = [ + 'Reino', + 'Familia', + 'Subfamilia', + 'Genero', + 'Especie', + 'Subespecie', + 'Variedade', + 'Autor' +] as const + +interface BuildNavItemsParams { + t: TFunction + auth: AuthContextValue + isCuradorOuOperador: boolean +} + +function navLink(to: string, label: string) { + return {label} +} + +function filterItems(items: Array): MenuItem[] { + return items.filter((item): item is MenuItem => item != null) +} + +export function getSelectedMenuKey(pathname: string): string { + const routes: Array<[string, string]> = [ + ['/tombos', 'tombos'], + ['/reinos', 'reinos'], + ['/familias', 'familias'], + ['/subfamilias', 'subfamilias'], + ['/generos', 'generos'], + ['/especies', 'especies'], + ['/subespecies', 'subespecies'], + ['/variedades', 'variedades'], + ['/autores', 'autores'], + ['/identificadores', 'identificadores'], + ['/coletores', 'coletores'], + ['/herbarios', 'herbarios'], + ['/fichas/tombos', 'fichaTombo'], + ['/usuarios', 'usuarios'], + ['/estados', 'estados'], + ['/cidades', 'cidades'], + ['/locais-coleta', 'localColeta'], + ['/mapa', 'mapa'], + ['/filtros', 'filtrosAvancados'], + ['/relatorio-por-periodo', 'relatorioPorPeriodo'], + ['/relatorio-inventario-especies', 'inventarioEspecies'], + ['/relatorio-coleta-data', 'coletaIntervaloData'], + ['/relatorio-coletor-data', 'coletaColetorIntervaloData'], + ['/relatorio-familias-genero', 'familiasGeneros'], + ['/relatorio-locais-coleta', 'locaisColeta'], + ['/relatorio-tombos-por-cidade', 'tombosPorCidade'], + ['/relatorio-quantidade-familia-generos', 'quantidadeFamiliaGenero'], + ['/relatorio-codigo-barras', 'codigoBarras'], + ['/relatorio-coordenadas-fora-poligono', 'diagnosticoErrosPosicionamento'], + ['/exportacao', 'exportacao'], + ['/reflora', 'reflora'], + ['/specieslink', 'specieslink'], + ['/rfid-configuracao', 'rfidConfiguracao'], + ['/rfid-conferencia', 'rfidConferencia'], + ['/rfid-vinculacao', 'rfidVinculacao'], + ['/rfid-inventario', 'rfidInventario'], + ['/pendencias', 'pendencias'], + ['/remessas', 'remessas'] + ] + + for (const [prefix, key] of routes) { + if (pathname === prefix || pathname.startsWith(`${prefix}/`)) { + return key + } + } + + return '' +} + +export function getOpenMenuKeys(pathname: string): string[] { + const selected = getSelectedMenuKey(pathname) + const groups: Record = { + taxonomia: [ + 'reinos', + 'familias', + 'subfamilias', + 'generos', + 'especies', + 'subespecies', + 'variedades', + 'autores' + ], + locais: [ + 'estados', + 'cidades', + 'localColeta' + ], + geolocalizacao: ['mapa', 'filtrosAvancados'], + relatorios: [ + 'relatorioPorPeriodo', + 'inventarioEspecies', + 'coletaIntervaloData', + 'coletaColetorIntervaloData', + 'familiasGeneros', + 'locaisColeta', + 'tombosPorCidade', + 'quantidadeFamiliaGenero', + 'codigoBarras', + 'diagnosticoErrosPosicionamento' + ], + servicos: ['reflora', 'specieslink'], + rfid: [ + 'rfidConfiguracao', + 'rfidConferencia', + 'rfidVinculacao', + 'rfidInventario' + ] + } + + return Object.entries(groups) + .filter(([, keys]) => keys.includes(selected)) + .map(([group]) => group) +} + +export function buildNavItems({ + t, + auth, + isCuradorOuOperador +}: BuildNavItemsParams): MenuProps['items'] { + const taxonomiaChildren = filterItems([ + auth.can('read', 'Reino') + ? { + key: 'reinos', + label: navLink('/reinos', t('mainLayout:reinos')), + title: t('mainLayout:reinos') + } + : null, + auth.can('read', 'Familia') + ? { + key: 'familias', + label: navLink('/familias', t('mainLayout:familias')), + title: t('mainLayout:familias') + } + : null, + auth.can('read', 'Subfamilia') + ? { + key: 'subfamilias', + label: navLink('/subfamilias', t('mainLayout:subfamilias')), + title: t('mainLayout:subfamilias') + } + : null, + auth.can('read', 'Genero') + ? { + key: 'generos', + label: navLink('/generos', t('mainLayout:generos')), + title: t('mainLayout:generos') + } + : null, + auth.can('read', 'Especie') + ? { + key: 'especies', + label: navLink('/especies', t('mainLayout:especies')), + title: t('mainLayout:especies') + } + : null, + auth.can('read', 'Subespecie') + ? { + key: 'subespecies', + label: navLink('/subespecies', t('mainLayout:subespecies')), + title: t('mainLayout:subespecies') + } + : null, + auth.can('read', 'Variedade') + ? { + key: 'variedades', + label: navLink('/variedades', t('mainLayout:variedades')), + title: t('mainLayout:variedades') + } + : null, + auth.can('read', 'Autor') + ? { + key: 'autores', + label: navLink('/autores', t('mainLayout:autores')), + title: t('mainLayout:autores') + } + : null + ]) + + const collectionItems = filterItems([ + auth.can('read', 'Tombo') + ? { + key: 'tombos', + icon: , + label: navLink('/tombos', t('mainLayout:tombos')), + title: t('mainLayout:tombos') + } + : null, + TAXONOMIA_RESOURCES.some(resource => auth.can('read', resource)) + ? { + key: 'taxonomia', + icon: , + label: t('mainLayout:taxonomia'), + title: t('mainLayout:taxonomia'), + children: taxonomiaChildren + } + : null, + auth.can('read', 'Identificador') + ? { + key: 'identificadores', + icon: , + label: navLink('/identificadores', t('mainLayout:identificadores')), + title: t('mainLayout:identificadores') + } + : null, + auth.can('read', 'Coletor') + ? { + key: 'coletores', + icon: , + label: navLink('/coletores', t('mainLayout:coletores')), + title: t('mainLayout:coletores') + } + : null, + auth.loggedIn + ? { + key: 'herbarios', + icon: , + label: navLink('/herbarios', t('mainLayout:herbarios')), + title: t('mainLayout:herbarios') + } + : null, + auth.loggedIn + ? { + key: 'fichaTombo', + icon: , + label: navLink('/fichas/tombos', t('mainLayout:fichaTombo')), + title: t('mainLayout:fichaTombo') + } + : null + ]) + + const peopleItems = filterItems([ + auth.can('read', 'Usuario') + ? { + key: 'usuarios', + icon: , + label: navLink('/usuarios', t('mainLayout:usuarios')), + title: t('mainLayout:usuarios') + } + : null + ]) + + const geographyItems = filterItems([ + { + key: 'locais', + icon: , + label: t('mainLayout:locais'), + title: t('mainLayout:locais'), + children: filterItems([ + { + key: 'estados', + label: navLink('/estados', t('mainLayout:estados')), + title: t('mainLayout:estados') + }, + { + key: 'cidades', + label: navLink('/cidades', t('mainLayout:cidades')), + title: t('mainLayout:cidades') + }, + auth.loggedIn + ? { + key: 'localColeta', + label: navLink('/locais-coleta', t('mainLayout:localColeta')), + title: t('mainLayout:localColeta') + } + : null + ]) + }, + { + key: 'geolocalizacao', + icon: , + label: t('mainLayout:geolocalizacao'), + title: t('mainLayout:geolocalizacao'), + children: [ + { + key: 'mapa', + label: navLink('/mapa', t('mainLayout:mapaCompleto')), + title: t('mainLayout:mapaCompleto') + }, + { + key: 'filtrosAvancados', + label: navLink('/filtros', t('mainLayout:filtrosAvancados')), + title: t('mainLayout:filtrosAvancados') + } + ] + } + ]) + + const reportChildren = auth.loggedIn + ? [ + { + key: 'relatorioPorPeriodo', + label: navLink('/relatorio-por-periodo', t('mainLayout:relatorioPorPeriodo')), + title: t('mainLayout:relatorioPorPeriodo') + }, + { + key: 'inventarioEspecies', + label: navLink('/relatorio-inventario-especies', t('mainLayout:inventarioEspecies')), + title: t('mainLayout:inventarioEspecies') + }, + { + key: 'coletaIntervaloData', + label: navLink('/relatorio-coleta-data', t('mainLayout:coletaIntervaloData')), + title: t('mainLayout:coletaIntervaloData') + }, + { + key: 'coletaColetorIntervaloData', + label: navLink('/relatorio-coletor-data', t('mainLayout:coletaColetorIntervaloData')), + title: t('mainLayout:coletaColetorIntervaloData') + }, + { + key: 'familiasGeneros', + label: navLink('/relatorio-familias-genero', t('mainLayout:familiasGeneros')), + title: t('mainLayout:familiasGeneros') + }, + { + key: 'locaisColeta', + label: navLink('/relatorio-locais-coleta', t('mainLayout:locaisColeta')), + title: t('mainLayout:locaisColeta') + }, + { + key: 'tombosPorCidade', + label: navLink('/relatorio-tombos-por-cidade', t('mainLayout:tombosPorCidade')), + title: t('mainLayout:tombosPorCidade') + }, + { + key: 'quantidadeFamiliaGenero', + label: navLink( + '/relatorio-quantidade-familia-generos', + t('mainLayout:quantidadeFamiliaGenero') + ), + title: t('mainLayout:quantidadeFamiliaGenero') + }, + { + key: 'codigoBarras', + label: navLink('/relatorio-codigo-barras', t('mainLayout:codigoBarras')), + title: t('mainLayout:codigoBarras') + }, + { + key: 'diagnosticoErrosPosicionamento', + label: navLink( + '/relatorio-coordenadas-fora-poligono', + t('mainLayout:diagnosticoErrosPosicionamento') + ), + title: t('mainLayout:diagnosticoErrosPosicionamento') + } + ] + : [] + + const serviceChildren = filterItems([ + auth.can('read', 'Reflora') + ? { + key: 'reflora', + label: navLink('/reflora', 'Reflora'), + title: 'Reflora' + } + : null, + auth.can('read', 'SpeciesLink') + ? { + key: 'specieslink', + label: navLink('/specieslink', 'speciesLink'), + title: 'speciesLink' + } + : null + ]) + + const operationsItems = filterItems([ + isCuradorOuOperador + ? { + key: 'pendencias', + icon: , + label: navLink('/pendencias', t('mainLayout:pendencias')), + title: t('mainLayout:pendencias') + } + : null, + isCuradorOuOperador + ? { + key: 'remessas', + icon: , + label: navLink('/remessas', t('mainLayout:remessas')), + title: t('mainLayout:remessas') + } + : null, + auth.loggedIn + ? { + key: 'relatorios', + icon: , + label: t('mainLayout:relatorios'), + title: t('mainLayout:relatorios'), + children: reportChildren + } + : null, + auth.can('export', 'Tombo') + ? { + key: 'exportacao', + icon: , + label: navLink('/exportacao', t('mainLayout:exportacao')), + title: t('mainLayout:exportacao') + } + : null, + serviceChildren.length > 0 + ? { + key: 'servicos', + icon: , + label: t('mainLayout:servicos'), + title: t('mainLayout:servicos'), + children: serviceChildren + } + : null, + auth.loggedIn + ? { + key: 'rfid', + icon: , + label: 'RFID', + title: 'RFID', + children: [ + { + key: 'rfidConfiguracao', + label: navLink('/rfid-configuracao', t('rfid:menu.configuration')), + title: t('rfid:menu.configuration') + }, + { + key: 'rfidConferencia', + label: navLink('/rfid-conferencia', t('rfid:menu.conference')), + title: t('rfid:menu.conference') + }, + { + key: 'rfidVinculacao', + label: navLink('/rfid-vinculacao', t('rfid:menu.link')), + title: t('rfid:menu.link') + }, + { + key: 'rfidInventario', + label: navLink('/rfid-inventario', t('rfid:menu.inventory')), + title: t('rfid:menu.inventory') + } + ] + } + : null + ]) + + return filterItems([ + collectionItems.length > 0 + ? { + type: 'group', + key: 'collection', + label: t('mainLayout:grupoColecao'), + children: collectionItems + } + : null, + peopleItems.length > 0 + ? { + type: 'group', + key: 'people', + label: t('mainLayout:grupoPessoasAcesso'), + children: peopleItems + } + : null, + geographyItems.length > 0 + ? { + type: 'group', + key: 'geography', + label: t('mainLayout:grupoGeografia'), + children: geographyItems + } + : null, + operationsItems.length > 0 + ? { + type: 'group', + key: 'operations', + label: t('mainLayout:grupoDadosOperacoes'), + children: operationsItems + } + : null + ]) +} diff --git a/src/libraries/router/index.ts b/src/libraries/router/index.ts index 96b486d..a2ac3bb 100644 --- a/src/libraries/router/index.ts +++ b/src/libraries/router/index.ts @@ -1,3 +1,6 @@ +export { useSearchParamsStore } from './searchParams' +export type { SearchParamsStore } from './searchParams' + export interface IndexRoute { index: true Component: React.ComponentType diff --git a/src/libraries/router/searchParams.ts b/src/libraries/router/searchParams.ts new file mode 100644 index 0000000..4765779 --- /dev/null +++ b/src/libraries/router/searchParams.ts @@ -0,0 +1,35 @@ +import { + useCallback, useMemo, useRef +} from 'react' + +import { useSearchParams } from 'react-router' + +export interface SearchParamsStore { + params: URLSearchParams + /** Read current params, apply updates, one navigate `replace: true`. `null` deletes the key. */ + patch: (updates: Record) => void +} + +export function useSearchParamsStore(): SearchParamsStore { + const [params, setParams] = useSearchParams() + const paramsRef = useRef(params) + paramsRef.current = params + + const patch = useCallback((updates: Record) => { + const next = new URLSearchParams(paramsRef.current) + for (const [key, value] of Object.entries(updates)) { + if (value === null || value === '') next.delete(key) + else next.set(key, value) + } + paramsRef.current = next + setParams(next, { replace: true }) + }, [setParams]) + + return useMemo(() => ({ + params, + patch + }), [ + params, + patch + ]) +} diff --git a/src/resources/permissions.ts b/src/resources/permissions.ts index 16eb372..814a9a8 100644 --- a/src/resources/permissions.ts +++ b/src/resources/permissions.ts @@ -20,7 +20,8 @@ const RESOURCES = [ 'Herbario', 'Coletor', 'Reflora', - 'SpeciesLink' + 'SpeciesLink', + 'Rfid' ] as const const ACTIONS = [ @@ -83,6 +84,10 @@ const ruleSpecificMapping: Record Rule [], diff --git a/src/router.tsx b/src/router.tsx index ebb8867..e8430a7 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,9 +1,10 @@ import { createBrowserRouter } from 'react-router' import { AppRoutes } from './App' +import { isRedesignEnabled } from './config/redesign' import InicioScreen from './features/login/InicioScreen' import { Ant4Layout } from './layouts/Ant4' -import { Ant6Layout } from './layouts/Ant6' +import { AppShellRoute } from './layouts/AppShellRoute' import { MainLayoutRoute } from './layouts/MainLayoutRoute' import { layout, route } from './libraries/router' import RecuperarSenhaScreen from './pages/recuperacaoSenha/RecuperarSenhaScreen' @@ -17,7 +18,7 @@ export const router = createBrowserRouter([ route('recuperar-senha', RecuperarSenhaScreen), route('inicio', InicioScreen), { - Component: MainLayoutRoute, + Component: isRedesignEnabled() ? AppShellRoute : MainLayoutRoute, children: [{ path: '*', Component: AppRoutes }] } ] diff --git a/src/theme/AppearanceContext.ts b/src/theme/AppearanceContext.ts new file mode 100644 index 0000000..b4bcc94 --- /dev/null +++ b/src/theme/AppearanceContext.ts @@ -0,0 +1,11 @@ +import { createContext } from 'react' + +import type { AppearanceMode } from './appearance' + +export interface AppearanceContextValue { + mode: AppearanceMode + isDark: boolean + setAppearance: (mode: AppearanceMode) => void +} + +export const AppearanceContext = createContext(undefined) diff --git a/src/theme/AppearanceProvider.tsx b/src/theme/AppearanceProvider.tsx new file mode 100644 index 0000000..8220af7 --- /dev/null +++ b/src/theme/AppearanceProvider.tsx @@ -0,0 +1,56 @@ +import { + useEffect, useMemo, useState, type ReactNode +} from 'react' + +import { useLocalStorage } from '@/hooks/useLocalStorage' + +import { + APPEARANCE_STORAGE_KEY, + isAppearanceMode, + resolveDark, + type AppearanceMode +} from './appearance' +import { AppearanceContext } from './AppearanceContext' + +function getSystemDark(): boolean { + if (typeof window === 'undefined' || !window.matchMedia) return false + return window.matchMedia('(prefers-color-scheme: dark)').matches +} + +export function AppearanceProvider({ children }: { children: ReactNode }) { + const [stored, setStored] = useLocalStorage( + APPEARANCE_STORAGE_KEY, + 'system' + ) + const [systemDark, setSystemDark] = useState(getSystemDark) + + useEffect(() => { + const media = window.matchMedia('(prefers-color-scheme: dark)') + const onChange = (event: MediaQueryListEvent) => { + setSystemDark(event.matches) + } + media.addEventListener('change', onChange) + return () => { + media.removeEventListener('change', onChange) + } + }, []) + + const mode: AppearanceMode = isAppearanceMode(stored) ? stored : 'system' + const isDark = resolveDark(mode, systemDark) + + const value = useMemo(() => ({ + mode, + isDark, + setAppearance: setStored + }), [ + mode, + isDark, + setStored + ]) + + return ( + + {children} + + ) +} diff --git a/src/theme/appearance.ts b/src/theme/appearance.ts new file mode 100644 index 0000000..63cc298 --- /dev/null +++ b/src/theme/appearance.ts @@ -0,0 +1,13 @@ +export const APPEARANCE_STORAGE_KEY = 'hcf.appearance' + +export type AppearanceMode = 'system' | 'light' | 'dark' + +export function isAppearanceMode(value: unknown): value is AppearanceMode { + return value === 'system' || value === 'light' || value === 'dark' +} + +export function resolveDark(mode: AppearanceMode, systemDark: boolean): boolean { + if (mode === 'dark') return true + if (mode === 'light') return false + return systemDark +} diff --git a/src/theme/theme.ts b/src/theme/theme.ts new file mode 100644 index 0000000..170fb10 --- /dev/null +++ b/src/theme/theme.ts @@ -0,0 +1,58 @@ +import type { ThemeConfig } from 'antd6' +import { theme } from 'antd6' + +const BRAND_GREEN = '#007A33' + +export const herbariumLayout = { + sidebarWidth: 240, + sidebarCollapsedWidth: 68, + headerHeight: 56, + pagePaddingDesktop: 24, + pagePaddingMobile: 16, + tableRowHeight: 42 +} as const + +export function buildHerbariumTheme(isDark: boolean): ThemeConfig { + return { + algorithm: isDark + ? [theme.darkAlgorithm, theme.compactAlgorithm] + : [theme.defaultAlgorithm, theme.compactAlgorithm], + token: { + colorPrimary: BRAND_GREEN, + colorLink: BRAND_GREEN, + borderRadius: 8, + fontSize: 14, + controlHeight: 32 + }, + components: { + Layout: { + headerHeight: herbariumLayout.headerHeight, + headerPadding: '0 16px', + siderBg: isDark ? undefined : '#F7F8FA', + headerBg: isDark ? undefined : '#FFFFFF', + bodyBg: isDark ? undefined : '#F5F6F8' + }, + Menu: { + itemBorderRadius: 6, + itemMarginInline: 8, + iconSize: 16 + }, + Table: { + headerBorderRadius: 8, + cellPaddingBlockMD: 10, + cellPaddingInlineMD: 12, + cellFontSize: 14, + cellFontSizeMD: 14 + }, + Button: { + borderRadius: 6 + }, + Input: { + borderRadius: 6 + }, + Select: { + borderRadius: 6 + } + } + } +} diff --git a/src/theme/useAppearance.ts b/src/theme/useAppearance.ts new file mode 100644 index 0000000..b1c0d9a --- /dev/null +++ b/src/theme/useAppearance.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react' + +import { AppearanceContext } from './AppearanceContext' + +export function useAppearance() { + const context = useContext(AppearanceContext) + if (!context) { + throw new Error('useAppearance must be used within an AppearanceProvider') + } + return context +} diff --git a/test/components/list/DataTable.spec.tsx b/test/components/list/DataTable.spec.tsx new file mode 100644 index 0000000..fb2a63e --- /dev/null +++ b/test/components/list/DataTable.spec.tsx @@ -0,0 +1,85 @@ +import { ConfigProvider } from 'antd6' +import { + describe, + expect, + test, + vi +} from 'vitest' + +import { DataTable } from '@/components/list/DataTable' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key + }) +})) + +interface Row { + key: number + nome: string +} + +function renderTable( + onChange = vi.fn(), + onRowClick?: (row: Row) => void +) { + return render( + + + rowKey="key" + data={[ + { key: 1, nome: 'Ana' }, + { key: 2, nome: 'Bia' } + ]} + columns={[ + { + key: 'nome', + title: 'Nome', + sortable: true, + render: row => row.nome + }, + { + key: 'actions', + title: 'Ações', + hideable: false, + render: () => ( + + ) + } + ]} + view={{ + page: 1, + pageSize: 20, + total: 2, + sort: null + }} + onChange={onChange} + onRowClick={onRowClick} + /> + + ) +} + +describe('DataTable', () => { + test('renders cells from column render and does not sort data', () => { + renderTable() + expect(screen.getByText('Ana')).toBeInTheDocument() + expect(screen.getByText('Bia')).toBeInTheDocument() + }) + + test('row click ignores interactive cells', async () => { + const user = userEvent.setup() + const onRowClick = vi.fn() + renderTable(vi.fn(), onRowClick) + + await user.click(screen.getAllByText('menu')[0]) + expect(onRowClick).not.toHaveBeenCalled() + + await user.click(screen.getByText('Ana')) + expect(onRowClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/components/list/listColumns.spec.ts b/test/components/list/listColumns.spec.ts new file mode 100644 index 0000000..6517b1b --- /dev/null +++ b/test/components/list/listColumns.spec.ts @@ -0,0 +1,66 @@ +import { + describe, + expect, + test +} from 'vitest' + +import type { DataTableColumn } from '@/components/list/DataTable' +import { columnCatalogConfig, visibleColumns } from '@/components/list/listColumns' + +interface Row { + key: number + nome: string +} + +const catalog: DataTableColumn[] = [ + { + key: 'nome', + title: 'Nome', + hideable: false, + sortable: true, + render: row => row.nome + }, + { + key: 'email', + title: 'E-mail', + sortable: true, + render: row => row.nome + }, + { + key: 'telefone', + title: 'Telefone', + render: row => row.nome + }, + { + key: 'actions', + title: 'Ações', + hideable: false, + render: () => null + } +] + +describe('columnCatalogConfig', () => { + test('derives settings, mandatory, and sortable keys from columns', () => { + const config = columnCatalogConfig(catalog) + + expect(config.columnKeys).toEqual([ + 'nome', + 'email', + 'telefone' + ]) + expect(config.mandatoryColumnKeys).toEqual(['nome']) + expect(config.allowedSortKeys).toEqual([ + 'nome', + 'email' + ]) + }) + + test('keeps mandatory columns when they are missing from visibleKeys', () => { + const next = visibleColumns(catalog, ['email']) + expect(next.map(column => column.key)).toEqual([ + 'nome', + 'email', + 'actions' + ]) + }) +}) diff --git a/test/contexts/Auth/AuthProvider.spec.tsx b/test/contexts/Auth/AuthProvider.spec.tsx index c9d642a..de0ba4b 100644 --- a/test/contexts/Auth/AuthProvider.spec.tsx +++ b/test/contexts/Auth/AuthProvider.spec.tsx @@ -1,6 +1,12 @@ import type { ReactNode } from 'react' -import { type Mock, vi } from 'vitest' +import { + describe, + expect, + test, + vi, + type Mock +} from 'vitest' import { TipoUsuario, Usuario } from '@/@types/components' import { AuthProvider } from '@/contexts/Auth/AuthProvider' @@ -9,7 +15,9 @@ import { ContainerProvider } from '@/contexts/Container/ContainerProvider' import { useCookie } from '@/hooks/useCookie' import { useLocalStorage } from '@/hooks/useLocalStorage' import { broker } from '@/libraries/events/Broker' -import { act, render, screen } from '@testing-library/react' +import { + act, render, screen +} from '@testing-library/react' import userEvent from '@testing-library/user-event' vi.mock('@/hooks/useCookie', () => ({ @@ -67,7 +75,7 @@ function AuthConsumer() { } describe('AuthProvider', () => { - it('renders children', () => { + test('renders children', () => { // arrange const mockSetAccessToken = vi.fn() const mockRemoveAccessToken = vi.fn(); @@ -99,7 +107,7 @@ describe('AuthProvider', () => { expect(screen.getByText('Child content')).toBeInTheDocument() }) - it('provides context with default permissions when logged out', () => { + test('provides context with default permissions when logged out', () => { // arrange const mockSetAccessToken = vi.fn() const mockRemoveAccessToken = vi.fn(); @@ -136,7 +144,7 @@ describe('AuthProvider', () => { expect(screen.getByTestId('can-all')).toHaveTextContent('yes') }) - it('logIn updates context', async () => { + test('logIn updates context', async () => { // arrange const mockSetAccessToken = vi.fn() const mockRemoveAccessToken = vi.fn() @@ -177,7 +185,7 @@ describe('AuthProvider', () => { expect(mockSetOldAccessToken).toHaveBeenCalledWith('new-token') }) - it('logOut clears context and calls remove functions', async () => { + test('logOut clears context and calls remove functions', async () => { // arrange const mockSetAccessToken = vi.fn() const mockRemoveAccessToken = vi.fn() @@ -220,7 +228,7 @@ describe('AuthProvider', () => { expect(screen.getByTestId('token')).toHaveTextContent('none') }) - it('logs out when http.unauthorized is emitted', async () => { + test('logs out when http.unauthorized is emitted', async () => { const mockSetAccessToken = vi.fn() const mockRemoveAccessToken = vi.fn() const mockSetLoggedUser = vi.fn() @@ -265,7 +273,7 @@ describe('AuthProvider', () => { }) describe('useAuth', () => { - it('throws when used outside AuthProvider', () => { + test('throws when used outside AuthProvider', () => { // arrange & act & assert expect(() => { render( diff --git a/test/contexts/Auth/Can.spec.tsx b/test/contexts/Auth/Can.spec.tsx index c8464d8..62cade1 100644 --- a/test/contexts/Auth/Can.spec.tsx +++ b/test/contexts/Auth/Can.spec.tsx @@ -1,6 +1,12 @@ import type { ReactNode } from 'react' -import { type Mock, vi } from 'vitest' +import { + describe, + expect, + test, + vi, + type Mock +} from 'vitest' import { AuthProvider } from '@/contexts/Auth/AuthProvider' import { Can } from '@/contexts/Auth/Can' @@ -33,7 +39,7 @@ function renderWithProviders(ui: ReactNode) { } describe('Can', () => { - it('renders children when user can perform action', () => { + test('renders children when user can perform action', () => { // arrange (useCookie as Mock).mockReturnValue([ undefined, @@ -65,7 +71,7 @@ describe('Can', () => { expect(screen.getByTestId('visible-content')).toBeInTheDocument() }) - it('renders null when user cannot perform action', () => { + test('renders null when user cannot perform action', () => { // arrange (useCookie as Mock).mockReturnValue([ undefined, @@ -97,7 +103,7 @@ describe('Can', () => { expect(screen.queryByTestId('hidden-content')).not.toBeInTheDocument() }) - it('with not prop renders children when user cannot perform action', () => { + test('with not prop renders children when user cannot perform action', () => { // arrange (useCookie as Mock).mockReturnValue([ undefined, @@ -129,7 +135,7 @@ describe('Can', () => { expect(screen.getByTestId('shown-when-cannot-create')).toBeInTheDocument() }) - it('with not prop renders null when user can perform action', () => { + test('with not prop renders null when user can perform action', () => { // arrange (useCookie as Mock).mockReturnValue([ undefined, diff --git a/test/contexts/Container/ContainerProvider.spec.tsx b/test/contexts/Container/ContainerProvider.spec.tsx index bb83d21..a7f6dff 100644 --- a/test/contexts/Container/ContainerProvider.spec.tsx +++ b/test/contexts/Container/ContainerProvider.spec.tsx @@ -1,5 +1,11 @@ import type { ReactNode } from 'react' +import { + describe, + expect, + test +} from 'vitest' + import { ContainerProvider } from '@/contexts/Container/ContainerProvider' import { useContainer } from '@/contexts/Container/useContainer' import { renderHook } from '@testing-library/react' @@ -13,7 +19,7 @@ function wrapper({ children }: { children: ReactNode }) { } describe('ContainerProvider', () => { - it('keeps the same HttpClient instance across re-renders', () => { + test('keeps the same HttpClient instance across re-renders', () => { const { result, rerender } = renderHook(() => useContainer(), { wrapper }) const first = result.current.httpClient diff --git a/test/features/usuarios/search.spec.ts b/test/features/usuarios/search.spec.ts new file mode 100644 index 0000000..78d14cc --- /dev/null +++ b/test/features/usuarios/search.spec.ts @@ -0,0 +1,34 @@ +import { + describe, + expect, + test +} from 'vitest' + +import { inferSearchField, toSearchFilters } from '@/features/usuarios/redesign/search' + +describe('toSearchFilters', () => { + test('maps e-mail queries to the email filter', () => { + expect(toSearchFilters('greta@utfpr.edu.br')).toEqual({ + email: 'greta@utfpr.edu.br' + }) + expect(inferSearchField('greta@utfpr.edu.br')).toBe('email') + }) + + test('maps phone-like queries to the phone filter', () => { + expect(toSearchFilters('+55 44 99968-2514')).toEqual({ + telefone: '+55 44 99968-2514' + }) + expect(inferSearchField('44999682514')).toBe('telefone') + }) + + test('maps other queries to the name filter', () => { + expect(toSearchFilters('Greta Aline')).toEqual({ + nome: 'Greta Aline' + }) + }) + + test('returns no filters for blank search', () => { + expect(toSearchFilters(' ')).toEqual({}) + expect(inferSearchField('')).toBeUndefined() + }) +}) diff --git a/test/hooks/list/routerWrapper.tsx b/test/hooks/list/routerWrapper.tsx new file mode 100644 index 0000000..1a69ce6 --- /dev/null +++ b/test/hooks/list/routerWrapper.tsx @@ -0,0 +1,21 @@ +import React, { type ReactNode } from 'react' + +import { + MemoryRouter, Route, Routes +} from 'react-router' + +export function SearchParamsWrapper({ + children, + initialEntry = '/usuarios' +}: { + children: ReactNode + initialEntry?: string +}) { + return ( + + + + + + ) +} diff --git a/test/hooks/list/useDataListParams.spec.tsx b/test/hooks/list/useDataListParams.spec.tsx new file mode 100644 index 0000000..c5513c2 --- /dev/null +++ b/test/hooks/list/useDataListParams.spec.tsx @@ -0,0 +1,115 @@ +import React from 'react' + +import { + describe, + expect, + test +} from 'vitest' + +import { ContainerProvider } from '@/contexts/Container/ContainerProvider' +import { useDataListParams } from '@/hooks/list/useDataListParams' +import { act, renderHook } from '@testing-library/react' + +import { SearchParamsWrapper } from './routerWrapper' + +function wrapper({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ) +} + +const listOptions = { + storageKey: 'hcf.users.columns.test', + defaults: { + q: '', + role: '' + }, + allowedSortKeys: ['nome'] as const, + columnKeys: ['nome', 'email'], + mandatoryColumnKeys: ['nome'] as const +} + +describe('useDataListParams', () => { + test('setFilter clears page in the same patch', () => { + const { result } = renderHook( + () => useDataListParams(listOptions), + { wrapper } + ) + + expect(result.current.filter.q).toBe('ana') + expect(result.current.page).toBe(3) + + act(() => { + result.current.setFilter({ q: 'bia' }) + }) + + expect(result.current.filter.q).toBe('bia') + expect(result.current.page).toBe(1) + }) + + test('applyTableChange resets page when sort changes', () => { + const { result } = renderHook( + () => useDataListParams(listOptions), + { wrapper } + ) + + act(() => { + result.current.applyTableChange({ + page: 3, + pageSize: 20, + sort: { + key: 'nome', + order: 'asc' + } + }) + }) + + expect(result.current.sort).toEqual({ + key: 'nome', + order: 'asc' + }) + expect(result.current.page).toBe(1) + }) + + test('hasActiveFilters ignores sort', () => { + const sortWrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ) + + const { result } = renderHook( + () => useDataListParams(listOptions), + { wrapper: sortWrapper } + ) + + expect(result.current.sort).toEqual({ + key: 'nome', + order: 'desc' + }) + expect(result.current.hasActiveFilters).toBe(false) + }) + + test('unknown sort keys are ignored', () => { + const unknownSortWrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ) + + const { result } = renderHook( + () => useDataListParams(listOptions), + { wrapper: unknownSortWrapper } + ) + + expect(result.current.sort).toBeNull() + }) +}) diff --git a/test/hooks/list/useUrlFilter.spec.tsx b/test/hooks/list/useUrlFilter.spec.tsx new file mode 100644 index 0000000..4332c4c --- /dev/null +++ b/test/hooks/list/useUrlFilter.spec.tsx @@ -0,0 +1,57 @@ +import { + describe, + expect, + test +} from 'vitest' + +import { useUrlFilter } from '@/hooks/list/useUrlFilter' +import { act, renderHook } from '@testing-library/react' + +import { SearchParamsWrapper } from './routerWrapper' + +function wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +const defaults = { + q: '', + role: '' +} + +describe('useUrlFilter', () => { + test('hydrates values from the URL and omits defaults on set', () => { + const { result } = renderHook( + () => useUrlFilter({ defaults }), + { wrapper } + ) + + expect(result.current.values).toEqual({ + q: 'ana', + role: '1' + }) + + act(() => { + result.current.set({ q: '' }) + }) + + expect(result.current.values.q).toBe('') + }) + + test('clear of some keys leaves the others', () => { + const { result } = renderHook( + () => useUrlFilter({ defaults }), + { wrapper } + ) + + act(() => { + result.current.clear(['q']) + }) + + expect(result.current.values.q).toBe('') + expect(result.current.values.role).toBe('1') + }) +}) diff --git a/test/hooks/list/useUrlPagination.spec.tsx b/test/hooks/list/useUrlPagination.spec.tsx new file mode 100644 index 0000000..634e01e --- /dev/null +++ b/test/hooks/list/useUrlPagination.spec.tsx @@ -0,0 +1,61 @@ +import { + describe, + expect, + test +} from 'vitest' + +import { useUrlPagination } from '@/hooks/list/useUrlPagination' +import { act, renderHook } from '@testing-library/react' + +import { SearchParamsWrapper } from './routerWrapper' + +describe('useUrlPagination', () => { + test('omits page 1 and default page size from the URL', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + + const { result } = renderHook(() => useUrlPagination(), { wrapper }) + + expect(result.current.page).toBe(1) + expect(result.current.pageSize).toBe(20) + + act(() => { + result.current.setPage(3) + }) + expect(result.current.page).toBe(3) + + act(() => { + result.current.setPage(1) + }) + expect(result.current.page).toBe(1) + }) + + test('changing page size resets to page 1', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + + const { result } = renderHook(() => useUrlPagination(), { wrapper }) + expect(result.current.page).toBe(4) + + act(() => { + result.current.setPageSize(50) + }) + + expect(result.current.page).toBe(1) + expect(result.current.pageSize).toBe(50) + }) + + test('invalid values fall back to defaults', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ) + + const { result } = renderHook(() => useUrlPagination(), { wrapper }) + expect(result.current.page).toBe(1) + expect(result.current.pageSize).toBe(20) + }) +}) diff --git a/test/hooks/query/useMutation.spec.ts b/test/hooks/query/useMutation.spec.ts new file mode 100644 index 0000000..db3ff36 --- /dev/null +++ b/test/hooks/query/useMutation.spec.ts @@ -0,0 +1,208 @@ +import { + describe, + expect, + test, + vi +} from 'vitest' + +import { useMutation } from '@/hooks/query/useMutation' +import { useQuery } from '@/hooks/query/useQuery' +import { + act, renderHook, waitFor +} from '@testing-library/react' + +describe('useMutation', () => { + test('does not call mutator on mount', () => { + const mutator = vi.fn().mockResolvedValue('ok') + + const { result } = renderHook(() => useMutation(mutator, ['no-mount-key'])) + + expect(mutator).not.toHaveBeenCalled() + expect(result.current.loading).toBe(false) + expect(result.current.data).toBeUndefined() + expect(result.current.error).toBeUndefined() + }) + + test('sets loading while trigger is pending', async () => { + let resolve!: (value: string) => void + const mutator = vi.fn( + () => new Promise(res => { + resolve = res + }) + ) + + const { result } = renderHook(() => useMutation(mutator, ['pending-key'])) + + let pending: Promise + act(() => { + pending = result.current.trigger() + }) + + await waitFor(() => { + expect(result.current.loading).toBe(true) + }) + + await act(async () => { + resolve('done') + await pending + }) + + expect(result.current.loading).toBe(false) + expect(result.current.data).toBe('done') + }) + + test('returns data when mutator resolves', async () => { + const mockData = { id: 1 } + const mutator = vi.fn().mockResolvedValue(mockData) + + const { result } = renderHook(() => useMutation(mutator, ['success-key'])) + + await act(async () => { + const data = await result.current.trigger({ name: 'a' }) + expect(data).toEqual(mockData) + }) + + expect(result.current.data).toEqual(mockData) + expect(result.current.error).toBeUndefined() + expect(mutator).toHaveBeenCalledWith({ name: 'a' }) + expect(mutator).not.toHaveBeenCalledWith(['success-key']) + }) + + test('forwards multiple trigger arguments to mutator', async () => { + const mutator = vi.fn().mockResolvedValue('ok') + + const { result } = renderHook(() => useMutation(mutator, ['multi-arg-key'])) + + await act(async () => { + await result.current.trigger(1, 'two') + }) + + expect(mutator).toHaveBeenCalledWith(1, 'two') + }) + + test('rejects and sets error when mutator fails', async () => { + const mockError = new Error('Mutation failed') + const mutator = vi.fn().mockRejectedValue(mockError) + + const { result } = renderHook(() => useMutation(mutator, ['error-key'])) + + await act(async () => { + await expect(result.current.trigger()).rejects.toThrow(mockError) + }) + + expect(result.current.error).toEqual(mockError) + }) + + test('resolves undefined when throwOnError is false', async () => { + const mockError = new Error('Mutation failed') + const mutator = vi.fn().mockRejectedValue(mockError) + + const { result } = renderHook(() => useMutation( + mutator, + ['no-throw-key'], + { throwOnError: false } + )) + + await act(async () => { + await expect(result.current.trigger()).resolves.toBeUndefined() + }) + + expect(result.current.error).toEqual(mockError) + }) + + test('does not call mutator when deps is null', async () => { + const mutator = vi.fn() + + const { result } = renderHook(() => useMutation(mutator, null)) + + await act(async () => { + await expect(result.current.trigger()).resolves.toBeUndefined() + }) + + expect(mutator).not.toHaveBeenCalled() + expect(result.current.loading).toBe(false) + }) + + test('clears data and error on reset', async () => { + const mutator = vi.fn().mockResolvedValue('ok') + + const { result } = renderHook(() => useMutation(mutator, ['reset-key'])) + + await act(async () => { + await result.current.trigger() + }) + + expect(result.current.data).toBe('ok') + + act(() => { + result.current.reset() + }) + + expect(result.current.data).toBeUndefined() + expect(result.current.error).toBeUndefined() + }) + + test('revalidates matching useQuery keys after success', async () => { + const listFetcher = vi.fn().mockResolvedValue({ items: 1 }) + const otherFetcher = vi.fn().mockResolvedValue({ items: 2 }) + const mutator = vi.fn().mockResolvedValue({ status: 201 }) + + const { result } = renderHook(() => ({ + list: useQuery(listFetcher, [ + 'revalidate-users', + { q: '' }, + 1 + ]), + other: useQuery(otherFetcher, ['revalidate-other', 1]), + mutation: useMutation( + mutator, + ['revalidate-users', 'create'], + { revalidate: [['revalidate-users']] } + ) + })) + + await waitFor(() => { + expect(result.current.list.loading).toBe(false) + expect(result.current.other.loading).toBe(false) + }) + + const listCalls = listFetcher.mock.calls.length + const otherCalls = otherFetcher.mock.calls.length + + await act(async () => { + await result.current.mutation.trigger({ nome: 'a' }) + }) + + await waitFor(() => { + expect(listFetcher.mock.calls.length).toBeGreaterThan(listCalls) + }) + + expect(otherFetcher.mock.calls.length).toBe(otherCalls) + }) + + test('does not revalidate when trigger fails', async () => { + const listFetcher = vi.fn().mockResolvedValue({ items: 1 }) + const mutator = vi.fn().mockRejectedValue(new Error('fail')) + + const { result } = renderHook(() => ({ + list: useQuery(listFetcher, ['revalidate-fail-users', 1]), + mutation: useMutation( + mutator, + ['revalidate-fail-users', 'create'], + { revalidate: [['revalidate-fail-users']] } + ) + })) + + await waitFor(() => { + expect(result.current.list.loading).toBe(false) + }) + + const listCalls = listFetcher.mock.calls.length + + await act(async () => { + await expect(result.current.mutation.trigger()).rejects.toThrow('fail') + }) + + expect(listFetcher.mock.calls.length).toBe(listCalls) + }) +}) diff --git a/test/hooks/query/useQuery.spec.ts b/test/hooks/query/useQuery.spec.ts index 28b722f..7528644 100644 --- a/test/hooks/query/useQuery.spec.ts +++ b/test/hooks/query/useQuery.spec.ts @@ -1,10 +1,15 @@ -import { vi } from 'vitest' +import { + describe, + expect, + test, + vi +} from 'vitest' import { useQuery } from '@/hooks/query/useQuery' import { renderHook, waitFor } from '@testing-library/react' describe('useQuery', () => { - it('returns loading true initially', () => { + test('returns loading true initially', () => { const fetcher = vi.fn().mockResolvedValue({ data: 'result' }) // act @@ -17,7 +22,7 @@ describe('useQuery', () => { expect(result.current.error).toBeUndefined() }) - it('returns data when fetcher resolves', async () => { + test('returns data when fetcher resolves', async () => { // arrange const mockData = { id: 1, name: 'test' } const fetcher = vi.fn().mockResolvedValue(mockData) @@ -35,7 +40,7 @@ describe('useQuery', () => { expect(fetcher).toHaveBeenCalledWith(['data-test-key']) }) - it('returns error when fetcher rejects', async () => { + test('returns error when fetcher rejects', async () => { // arrange const mockError = new Error('Fetch failed') const fetcher = vi.fn().mockRejectedValue(mockError) @@ -52,7 +57,7 @@ describe('useQuery', () => { expect(result.current.data).toBeUndefined() }) - it('passes deps to fetcher', async () => { + test('passes deps to fetcher', async () => { const fetcher = vi.fn().mockResolvedValue('ok') // act @@ -64,7 +69,7 @@ describe('useQuery', () => { }) }) - it('supports null deps to disable fetch', () => { + test('supports null deps to disable fetch', () => { const fetcher = vi.fn() // act diff --git a/test/hooks/useCookie.spec.tsx b/test/hooks/useCookie.spec.tsx index 7f5eecf..45b5ce9 100644 --- a/test/hooks/useCookie.spec.tsx +++ b/test/hooks/useCookie.spec.tsx @@ -1,6 +1,12 @@ import React from 'react' -import { type Mock, vi } from 'vitest' +import { + describe, + expect, + test, + vi, + type Mock +} from 'vitest' import { ContainerProvider } from '@/contexts/Container/ContainerProvider' import { @@ -28,7 +34,7 @@ function wrapper({ children }: { children: React.ReactNode }) { } describe('useCookie', () => { - it('returns initial value from getCookie', () => { + test('returns initial value from getCookie', () => { // arrange mockGetCookie.mockReturnValue('initial-token') @@ -40,7 +46,7 @@ describe('useCookie', () => { expect(mockGetCookie).toHaveBeenCalledWith('access_token') }) - it('returns undefined when cookie does not exist', () => { + test('returns undefined when cookie does not exist', () => { // arrange mockGetCookie.mockReturnValue(undefined) @@ -51,7 +57,7 @@ describe('useCookie', () => { expect(result.current[0]).toBeUndefined() }) - it('setValue calls setCookie and updates state', () => { + test('setValue calls setCookie and updates state', () => { // arrange mockGetCookie.mockReturnValue('new-token') const { result } = renderHook(() => useCookie('access_token'), { wrapper }) @@ -67,7 +73,7 @@ describe('useCookie', () => { expect(result.current[0]).toBe('new-token') }) - it('setValue accepts options', () => { + test('setValue accepts options', () => { // arrange mockGetCookie.mockReturnValue(undefined) const { result } = renderHook(() => useCookie('access_token'), { wrapper }) @@ -85,7 +91,7 @@ describe('useCookie', () => { }) }) - it('removeValue calls removeCookie and clears state', () => { + test('removeValue calls removeCookie and clears state', () => { // arrange mockGetCookie.mockReturnValue('existing-token') const { result } = renderHook(() => useCookie('access_token'), { wrapper }) @@ -103,7 +109,7 @@ describe('useCookie', () => { expect(result.current[0]).toBeUndefined() }) - it('returns tuple with [value, setValue, removeValue]', () => { + test('returns tuple with [value, setValue, removeValue]', () => { // arrange mockGetCookie.mockReturnValue(undefined) diff --git a/test/hooks/useLocalStorage.spec.tsx b/test/hooks/useLocalStorage.spec.tsx index c798753..95f7f60 100644 --- a/test/hooks/useLocalStorage.spec.tsx +++ b/test/hooks/useLocalStorage.spec.tsx @@ -1,5 +1,11 @@ import React from 'react' +import { + describe, + expect, + test +} from 'vitest' + import { ContainerProvider } from '@/contexts/Container/ContainerProvider' import { useLocalStorage } from '@/hooks/useLocalStorage' import { renderHook, act } from '@testing-library/react' @@ -13,7 +19,7 @@ function wrapper({ children }: { children: React.ReactNode }) { } describe('useLocalStorage', () => { - it('returns initial value when key exists', () => { + test('returns initial value when key exists', () => { // arrange window.localStorage.clear() window.localStorage.setItem('test-key', JSON.stringify('stored-value')) @@ -25,7 +31,7 @@ describe('useLocalStorage', () => { expect(result.current[0]).toBe('stored-value') }) - it('returns initialValue when key does not exist', () => { + test('returns initialValue when key does not exist', () => { // arrange window.localStorage.clear() @@ -39,7 +45,7 @@ describe('useLocalStorage', () => { expect(result.current[0]).toBe('default') }) - it('returns undefined when key does not exist and no initialValue', () => { + test('returns undefined when key does not exist and no initialValue', () => { // arrange window.localStorage.clear() @@ -50,7 +56,7 @@ describe('useLocalStorage', () => { expect(result.current[0]).toBeUndefined() }) - it('setValue updates localStorage and state', () => { + test('setValue updates localStorage and state', () => { // arrange window.localStorage.clear() const { result } = renderHook(() => useLocalStorage('test-key'), { wrapper }) @@ -66,7 +72,7 @@ describe('useLocalStorage', () => { expect(result.current[0]).toBe('new-value') }) - it('setValue with object stores JSON', () => { + test('setValue with object stores JSON', () => { // arrange window.localStorage.clear() const { result } = renderHook(() => useLocalStorage<{ id: number }>('obj-key'), { @@ -84,7 +90,7 @@ describe('useLocalStorage', () => { expect(result.current[0]).toEqual({ id: 42 }) }) - it('setValue undefined removes from localStorage', () => { + test('setValue undefined removes from localStorage', () => { // arrange window.localStorage.clear() window.localStorage.setItem('test-key', JSON.stringify('value')) @@ -101,7 +107,7 @@ describe('useLocalStorage', () => { expect(result.current[0]).toBeUndefined() }) - it('removeValue clears localStorage and state', () => { + test('removeValue clears localStorage and state', () => { // arrange window.localStorage.clear() window.localStorage.setItem('test-key', JSON.stringify('value')) @@ -120,7 +126,7 @@ describe('useLocalStorage', () => { expect(result.current[0]).toBeUndefined() }) - it('returns tuple with [value, setValue, removeValue]', () => { + test('returns tuple with [value, setValue, removeValue]', () => { // arrange window.localStorage.clear() diff --git a/test/index.spec.ts b/test/index.spec.ts index 137d5ca..3c993c6 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -1,3 +1,5 @@ -it('should be ok', () => { +import { expect, test } from 'vitest' + +test('should be ok', () => { expect(true).toBe(true) }) diff --git a/test/layouts/navItems.spec.ts b/test/layouts/navItems.spec.ts new file mode 100644 index 0000000..c172bbc --- /dev/null +++ b/test/layouts/navItems.spec.ts @@ -0,0 +1,26 @@ +import { + describe, + expect, + test +} from 'vitest' + +import { + getOpenMenuKeys, + getSelectedMenuKey +} from '@/layouts/navItems' + +describe('sidebar route matching', () => { + test('selects the users item for nested user routes', () => { + expect(getSelectedMenuKey('/usuarios')).toBe('usuarios') + expect(getSelectedMenuKey('/usuarios/12')).toBe('usuarios') + }) + + test('opens the taxonomy submenu on taxonomy pages', () => { + expect(getSelectedMenuKey('/familias')).toBe('familias') + expect(getOpenMenuKeys('/familias')).toEqual(['taxonomia']) + }) + + test('opens reports when viewing a report route', () => { + expect(getOpenMenuKeys('/relatorio-codigo-barras')).toEqual(['relatorios']) + }) +}) diff --git a/test/libraries/auth/Manager.spec.ts b/test/libraries/auth/Manager.spec.ts index 9f11c82..29a48a7 100644 --- a/test/libraries/auth/Manager.spec.ts +++ b/test/libraries/auth/Manager.spec.ts @@ -1,7 +1,13 @@ +import { + describe, + expect, + test +} from 'vitest' + import { Manager, type Rule } from '@/libraries/auth/Manager' describe('Manager', () => { - it('returns a manager with the given rules', () => { + test('returns a manager with the given rules', () => { // arrange const rules: Rule[] = [{ resource: 'Tombo', action: 'read' }] @@ -14,7 +20,7 @@ describe('Manager', () => { expect(manager.rules[0]).toEqual({ resource: 'Tombo', action: 'read' }) }) - it('returns can=true when action and resource match a rule', () => { + test('returns can=true when action and resource match a rule', () => { // arrange const rules: Rule[] = [{ resource: 'Tombo', action: 'read' }] const manager = new Manager({ rules }) @@ -26,7 +32,7 @@ describe('Manager', () => { expect(result).toBe(true) }) - it('returns can=false when action is not allowed', () => { + test('returns can=false when action is not allowed', () => { // arrange const rules: Rule[] = [{ resource: 'Tombo', action: 'read' }] const manager = new Manager({ rules }) @@ -42,7 +48,7 @@ describe('Manager', () => { expect(canDelete).toBe(false) }) - it('returns can=false when resource does not match', () => { + test('returns can=false when resource does not match', () => { // arrange const rules: Rule[] = [{ resource: 'Tombo', action: 'read' }] const manager = new Manager({ rules }) @@ -54,7 +60,7 @@ describe('Manager', () => { expect(result).toBe(false) }) - it('supports multiple rules for different resources', () => { + test('supports multiple rules for different resources', () => { // arrange const rules: Rule[] = [ { resource: 'Tombo', action: 'read' }, @@ -75,7 +81,7 @@ describe('Manager', () => { expect(canUpdateFamilia).toBe(false) }) - it('handles empty rules', () => { + test('handles empty rules', () => { // arrange const manager = new Manager({ rules: [] }) @@ -87,7 +93,7 @@ describe('Manager', () => { expect(canRead).toBe(false) }) - it('supports rules with multiple actions as array', () => { + test('supports rules with multiple actions as array', () => { // arrange const rules: Rule[] = [ { @@ -114,7 +120,7 @@ describe('Manager', () => { expect(canDelete).toBe(false) }) - it.skip('manage returns true when user has any action on resource', () => { + test.skip('manage returns true when user has any action on resource', () => { // arrange const rules: Rule[] = [ { @@ -142,7 +148,7 @@ describe('Manager', () => { expect(canManageTombo).toBe(false) }) - it.skip('manage returns false when user lacks all actions', () => { + test.skip('manage returns false when user lacks all actions', () => { // arrange const rules: Rule[] = [{ resource: 'Usuario', action: ['read', 'update'] }] const manager = new Manager({ rules }) @@ -159,7 +165,7 @@ describe('Manager', () => { }) describe('canAny', () => { - it('returns true when any action is allowed', () => { + test('returns true when any action is allowed', () => { // arrange const rules: Rule[] = [{ resource: 'Tombo', action: 'read' }] const manager = new Manager({ rules }) @@ -173,7 +179,7 @@ describe('Manager', () => { expect(result2).toBe(true) }) - it('returns false when no action is allowed', () => { + test('returns false when no action is allowed', () => { // arrange const rules: Rule[] = [{ resource: 'Tombo', action: 'read' }] const manager = new Manager({ rules }) @@ -185,7 +191,7 @@ describe('Manager', () => { expect(result).toBe(false) }) - it('returns false for empty actions array', () => { + test('returns false for empty actions array', () => { // arrange const manager = new Manager({ rules: [{ resource: 'Tombo', action: 'read' }] }) @@ -198,7 +204,7 @@ describe('Manager', () => { }) describe('canAll', () => { - it('returns true when all actions are allowed', () => { + test('returns true when all actions are allowed', () => { // arrange const rules: Rule[] = [{ resource: 'Familia', action: ['read', 'create'] }] const manager = new Manager({ rules }) @@ -210,7 +216,7 @@ describe('Manager', () => { expect(result).toBe(true) }) - it('returns false when any action is not allowed', () => { + test('returns false when any action is not allowed', () => { // arrange const rules: Rule[] = [{ resource: 'Tombo', action: 'read' }] const manager = new Manager({ rules }) @@ -222,7 +228,7 @@ describe('Manager', () => { expect(result).toBe(false) }) - it('returns false for empty actions array', () => { + test('returns false for empty actions array', () => { // arrange const manager = new Manager({ rules: [{ resource: 'Tombo', action: 'read' }] @@ -235,7 +241,7 @@ describe('Manager', () => { expect(result).toBe(false) }) - it('returns true for all actions when resource has full CRUD', () => { + test('returns true for all actions when resource has full CRUD', () => { // arrange const manager = new Manager({ rules: [ diff --git a/test/libraries/events/Broker.spec.ts b/test/libraries/events/Broker.spec.ts index 66b3cff..5f3e471 100644 --- a/test/libraries/events/Broker.spec.ts +++ b/test/libraries/events/Broker.spec.ts @@ -1,4 +1,9 @@ -import { vi } from 'vitest' +import { + describe, + expect, + test, + vi +} from 'vitest' import { Broker } from '@/libraries/events/Broker' @@ -9,7 +14,7 @@ type TestEvents = { describe('Broker', () => { describe('subscribe', () => { - it('registers a listener for an event', () => { + test('registers a listener for an event', () => { // arrange const broker = new Broker() const callback = vi.fn() @@ -23,7 +28,7 @@ describe('Broker', () => { expect(callback).toHaveBeenCalledWith('hello') }) - it('returns this for chaining', () => { + test('returns this for chaining', () => { // arrange const broker = new Broker() const callback = vi.fn() @@ -35,7 +40,7 @@ describe('Broker', () => { expect(result).toBe(broker) }) - it('supports multiple listeners for the same event', () => { + test('supports multiple listeners for the same event', () => { // arrange const broker = new Broker() const callback1 = vi.fn() @@ -51,7 +56,7 @@ describe('Broker', () => { expect(callback2).toHaveBeenCalledWith('hello') }) - it('passes correct arguments to listeners', () => { + test('passes correct arguments to listeners', () => { // arrange const broker = new Broker() const callback = vi.fn() @@ -66,7 +71,7 @@ describe('Broker', () => { }) describe('unsubscribe', () => { - it('removes a listener', () => { + test('removes a listener', () => { // arrange const broker = new Broker() const callback = vi.fn() @@ -80,7 +85,7 @@ describe('Broker', () => { expect(callback).not.toHaveBeenCalled() }) - it('returns this for chaining', () => { + test('returns this for chaining', () => { // arrange const broker = new Broker() const callback = vi.fn() @@ -93,7 +98,7 @@ describe('Broker', () => { expect(result).toBe(broker) }) - it('does not affect other listeners when unsubscribing one', () => { + test('does not affect other listeners when unsubscribing one', () => { // arrange const broker = new Broker() const callback1 = vi.fn() @@ -110,7 +115,7 @@ describe('Broker', () => { expect(callback2).toHaveBeenCalledWith('hello') }) - it('handles unsubscribe of non-existent listener gracefully', () => { + test('handles unsubscribe of non-existent listener gracefully', () => { // arrange const broker = new Broker() const callback = vi.fn() @@ -121,7 +126,7 @@ describe('Broker', () => { }) describe('emit', () => { - it('does nothing when no listeners are registered', () => { + test('does nothing when no listeners are registered', () => { // arrange const broker = new Broker() @@ -129,7 +134,7 @@ describe('Broker', () => { expect(() => broker.emit('test.event', 'hello')).not.toThrow() }) - it('returns this for chaining', () => { + test('returns this for chaining', () => { // arrange const broker = new Broker() const callback = vi.fn() diff --git a/test/libraries/http/HttpClient.spec.ts b/test/libraries/http/HttpClient.spec.ts index 619095d..4221d40 100644 --- a/test/libraries/http/HttpClient.spec.ts +++ b/test/libraries/http/HttpClient.spec.ts @@ -1,5 +1,12 @@ import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios' -import { vi } from 'vitest' +import { + afterEach, + beforeEach, + describe, + expect, + test, + vi +} from 'vitest' import { Broker } from '@/libraries/events/Broker' import { HttpClient } from '@/libraries/http/HttpClient' @@ -23,7 +30,7 @@ describe('HttpClient', () => { nextStatus = 200 nextData = { ok: true } - axios.defaults.adapter = async config => { + axios.defaults.adapter = config => { lastConfig = config if (nextStatus >= 400) { const error = new AxiosError('Request failed') @@ -38,13 +45,13 @@ describe('HttpClient', () => { throw error } - return { + return Promise.resolve({ data: nextData, status: nextStatus, statusText: 'OK', headers: {}, config - } as AdapterResult + } as AdapterResult) } }) @@ -59,7 +66,7 @@ describe('HttpClient', () => { return typeof value === 'string' ? value : undefined } - it('sends Authorization Bearer when accessTokenSource returns a token', async () => { + test('sends Authorization Bearer when accessTokenSource returns a token', async () => { const client = new HttpClient({ baseUrl: 'https://api.example.com', broker: new Broker(), @@ -71,7 +78,7 @@ describe('HttpClient', () => { expect(authorizationHeader()).toBe('Bearer session-token') }) - it('omits Authorization when accessTokenSource returns undefined', async () => { + test('omits Authorization when accessTokenSource returns undefined', async () => { const client = new HttpClient({ baseUrl: 'https://api.example.com', broker: new Broker(), @@ -83,7 +90,7 @@ describe('HttpClient', () => { expect(authorizationHeader()).toBeUndefined() }) - it('omits Authorization when accessTokenSource is not provided', async () => { + test('omits Authorization when accessTokenSource is not provided', async () => { const client = new HttpClient({ baseUrl: 'https://api.example.com', broker: new Broker() @@ -94,7 +101,7 @@ describe('HttpClient', () => { expect(authorizationHeader()).toBeUndefined() }) - it('emits http.unauthorized and rejects on 401', async () => { + test('emits http.unauthorized and rejects on 401', async () => { const broker = new Broker() const onUnauthorized = vi.fn() broker.subscribe('http.unauthorized', onUnauthorized) @@ -109,7 +116,7 @@ describe('HttpClient', () => { expect(onUnauthorized).toHaveBeenCalledTimes(1) }) - it('returns HttpClientResponse from delete', async () => { + test('returns HttpClientResponse from delete', async () => { nextData = undefined nextStatus = 204 const client = new HttpClient({ diff --git a/test/libraries/router/searchParams.spec.tsx b/test/libraries/router/searchParams.spec.tsx new file mode 100644 index 0000000..2d2ebbc --- /dev/null +++ b/test/libraries/router/searchParams.spec.tsx @@ -0,0 +1,61 @@ +import { + MemoryRouter, Route, Routes, useLocation +} from 'react-router' +import { + describe, + expect, + test +} from 'vitest' + +import { useSearchParamsStore } from '@/libraries/router' +import { act, renderHook } from '@testing-library/react' + +function wrapper({ children }: { children: React.ReactNode }) { + return ( + + + + + + ) +} + +function useStoreAndSearch() { + const store = useSearchParamsStore() + const location = useLocation() + return { + store, + search: location.search + } +} + +describe('useSearchParamsStore', () => { + test('writes a key and omits empty values', () => { + const { result } = renderHook(() => useStoreAndSearch(), { wrapper }) + + act(() => { + result.current.store.patch({ q: 'ana' }) + }) + + expect(result.current.search).toBe('?q=ana') + + act(() => { + result.current.store.patch({ q: null }) + }) + + expect(result.current.search).toBe('') + }) + + test('applies two sequential patches in one turn without dropping keys', () => { + const { result } = renderHook(() => useStoreAndSearch(), { wrapper }) + + act(() => { + result.current.store.patch({ q: 'ana' }) + result.current.store.patch({ role: '1' }) + }) + + const params = new URLSearchParams(result.current.search) + expect(params.get('q')).toBe('ana') + expect(params.get('role')).toBe('1') + }) +}) diff --git a/test/libraries/session/CookieAccessTokenSource.spec.ts b/test/libraries/session/CookieAccessTokenSource.spec.ts index ffa4c1f..5811550 100644 --- a/test/libraries/session/CookieAccessTokenSource.spec.ts +++ b/test/libraries/session/CookieAccessTokenSource.spec.ts @@ -1,4 +1,11 @@ -import { type Mock, vi } from 'vitest' +import { + beforeEach, + describe, + expect, + test, + vi, + type Mock +} from 'vitest' import { getCookie } from '@/helpers/cookie' import { CookieAccessTokenSource } from '@/libraries/session/CookieAccessTokenSource' @@ -14,7 +21,7 @@ describe('CookieAccessTokenSource', () => { mockGetCookie.mockReset() }) - it('returns the Access_Token cookie value', () => { + test('returns the Access_Token cookie value', () => { mockGetCookie.mockReturnValue('session-token') const source = new CookieAccessTokenSource() @@ -23,7 +30,7 @@ describe('CookieAccessTokenSource', () => { expect(mockGetCookie).toHaveBeenCalledWith('Access_Token') }) - it('returns undefined when the cookie is missing', () => { + test('returns undefined when the cookie is missing', () => { mockGetCookie.mockReturnValue(undefined) const source = new CookieAccessTokenSource() @@ -31,7 +38,7 @@ describe('CookieAccessTokenSource', () => { expect(source.getAccessToken()).toBeUndefined() }) - it('reads a custom cookie name', () => { + test('reads a custom cookie name', () => { mockGetCookie.mockReturnValue('other-token') const source = new CookieAccessTokenSource('Other_Token') diff --git a/test/theme/appearance.spec.ts b/test/theme/appearance.spec.ts new file mode 100644 index 0000000..9e97880 --- /dev/null +++ b/test/theme/appearance.spec.ts @@ -0,0 +1,19 @@ +import { + describe, + expect, + test +} from 'vitest' + +import { resolveDark } from '@/theme/appearance' + +describe('resolveDark', () => { + test('follows the operating system when appearance is system', () => { + expect(resolveDark('system', true)).toBe(true) + expect(resolveDark('system', false)).toBe(false) + }) + + test('ignores the operating system when appearance is set manually', () => { + expect(resolveDark('light', true)).toBe(false) + expect(resolveDark('dark', false)).toBe(true) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 6faafc6..406ec25 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,6 @@ "resolveJsonModule": true, "isolatedModules": true, "types": [ - "vitest/globals", "@testing-library/jest-dom/vitest" ], "baseUrl": ".", @@ -36,10 +35,11 @@ }, "include": [ "src/**/*", + "test/**/*", ".config/**/*", "vite.config.ts", "eslint.config.mts", - "vitest.config.mts", + "vitest.config.mts" ], "exclude": [ "build/", diff --git a/vitest.config.mts b/vitest.config.mts index 3ec69d5..36b894f 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -3,7 +3,6 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { - globals: true, environment: 'jsdom', mockReset: true, clearMocks: true,