Added language selection and translation files

This commit is contained in:
gulimabr
2025-12-04 15:42:43 -03:00
parent 469ecc8054
commit 51233ec989
32 changed files with 5526 additions and 313 deletions

4124
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"i18next": "^25.7.1",
"i18next-browser-languagedetector": "^8.2.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^16.3.5",
"react-router-dom": "^6.28.0" "react-router-dom": "^6.28.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,56 @@
import { useTranslation } from 'react-i18next'
import { LANGUAGES, type LanguageCode } from '@/i18n'
interface LanguageSelectorProps {
/** Additional CSS classes */
className?: string
/** Compact mode - shows only flag and code */
compact?: boolean
}
/**
* A dropdown component for selecting the application language.
* Uses i18next for language switching and persists choice to localStorage.
*/
export default function LanguageSelector({ className = '', compact = false }: LanguageSelectorProps) {
const { i18n, t } = useTranslation('common')
const handleLanguageChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newLang = e.target.value as LanguageCode
i18n.changeLanguage(newLang)
}
return (
<div className={`relative ${className}`}>
<select
value={i18n.language}
onChange={handleLanguageChange}
className="appearance-none bg-white border border-gray-300 rounded-md pl-3 pr-8 py-1.5 text-sm text-gray-700 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent cursor-pointer"
title={t('selectLanguage')}
aria-label={t('selectLanguage')}
>
{LANGUAGES.map((lang) => (
<option key={lang.code} value={lang.code}>
{compact ? `${lang.flag} ${lang.code.toUpperCase()}` : `${lang.flag} ${lang.label}`}
</option>
))}
</select>
{/* Custom dropdown arrow */}
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
className="h-4 w-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
)
}

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import Navbar from './Navbar' import Navbar from './Navbar'
interface LayoutProps { interface LayoutProps {
@@ -6,6 +7,8 @@ interface LayoutProps {
} }
export default function Layout({ children }: LayoutProps) { export default function Layout({ children }: LayoutProps) {
const { t } = useTranslation('navbar')
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
<Navbar /> <Navbar />
@@ -17,8 +20,7 @@ export default function Layout({ children }: LayoutProps) {
<footer className="border-t border-gray-200 bg-white py-6"> <footer className="border-t border-gray-200 bg-white py-6">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<p className="text-center text-sm text-gray-500"> <p className="text-center text-sm text-gray-500">
&copy; {new Date().getFullYear()} Requirements Periodic Table. All rights &copy; {new Date().getFullYear()} {t('brand')}.
reserved.
</p> </p>
</div> </div>
</footer> </footer>

View File

@@ -1,8 +1,11 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '@/hooks' import { useAuth } from '@/hooks'
import LanguageSelector from './LanguageSelector'
export default function Navbar() { export default function Navbar() {
const { user, isAuthenticated, login, logout } = useAuth() const { user, isAuthenticated, login, logout } = useAuth()
const { t } = useTranslation('navbar')
return ( return (
<nav className="border-b border-gray-200 bg-white shadow-sm"> <nav className="border-b border-gray-200 bg-white shadow-sm">
@@ -27,23 +30,26 @@ export default function Navbar() {
<rect x="9" y="3" width="6" height="4" rx="1" /> <rect x="9" y="3" width="6" height="4" rx="1" />
<path d="M9 12l2 2 4-4" /> <path d="M9 12l2 2 4-4" />
</svg> </svg>
<span>Requirements Periodic Table</span> <span>{t('brand')}</span>
</Link> </Link>
</div> </div>
{/* Navigation Links */} {/* Navigation Links */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Language Selector */}
<LanguageSelector compact />
{isAuthenticated ? ( {isAuthenticated ? (
<> <>
<Link <Link
to="/dashboard" to="/dashboard"
className="rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 hover:text-gray-900" className="rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 hover:text-gray-900"
> >
Dashboard {t('dashboard')}
</Link> </Link>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600">
Hello,{' '} {t('hello')},{' '}
<span className="font-medium text-gray-900"> <span className="font-medium text-gray-900">
{user?.full_name || user?.preferred_username} {user?.full_name || user?.preferred_username}
</span> </span>
@@ -52,7 +58,7 @@ export default function Navbar() {
onClick={logout} onClick={logout}
className="rounded-md bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200" className="rounded-md bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200"
> >
Logout {t('logout')}
</button> </button>
</div> </div>
</> </>
@@ -61,7 +67,7 @@ export default function Navbar() {
onClick={login} onClick={login}
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-700" className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-700"
> >
Login {t('login')}
</button> </button>
)} )}
</div> </div>

View File

@@ -1,3 +1,4 @@
export { default as Layout } from './Layout' export { default as Layout } from './Layout'
export { default as Navbar } from './Navbar' export { default as Navbar } from './Navbar'
export { default as ProtectedRoute } from './ProtectedRoute' export { default as ProtectedRoute } from './ProtectedRoute'
export { default as LanguageSelector } from './LanguageSelector'

9
frontend/src/i18n/i18n.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import 'i18next';
import { resources, defaultNS } from './index';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: typeof defaultNS;
resources: typeof resources['en'];
}
}

View File

@@ -0,0 +1,96 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// Import English translations
import enCommon from './locales/en/common.json';
import enNavbar from './locales/en/navbar.json';
import enHome from './locales/en/home.json';
import enDashboard from './locales/en/dashboard.json';
import enRequirements from './locales/en/requirements.json';
import enRequirementDetail from './locales/en/requirementDetail.json';
import enAdmin from './locales/en/admin.json';
import enValidation from './locales/en/validation.json';
import enErrors from './locales/en/errors.json';
// Import Portuguese translations
import ptCommon from './locales/pt/common.json';
import ptNavbar from './locales/pt/navbar.json';
import ptHome from './locales/pt/home.json';
import ptDashboard from './locales/pt/dashboard.json';
import ptRequirements from './locales/pt/requirements.json';
import ptRequirementDetail from './locales/pt/requirementDetail.json';
import ptAdmin from './locales/pt/admin.json';
import ptValidation from './locales/pt/validation.json';
import ptErrors from './locales/pt/errors.json';
// Define available languages for the language selector
export const LANGUAGES = [
{ code: 'en', label: 'English', flag: '🇺🇸' },
{ code: 'pt', label: 'Português', flag: '🇧🇷' },
] as const;
export type LanguageCode = typeof LANGUAGES[number]['code'];
// Define resources with all namespaces
export const resources = {
en: {
common: enCommon,
navbar: enNavbar,
home: enHome,
dashboard: enDashboard,
requirements: enRequirements,
requirementDetail: enRequirementDetail,
admin: enAdmin,
validation: enValidation,
errors: enErrors,
},
pt: {
common: ptCommon,
navbar: ptNavbar,
home: ptHome,
dashboard: ptDashboard,
requirements: ptRequirements,
requirementDetail: ptRequirementDetail,
admin: ptAdmin,
validation: ptValidation,
errors: ptErrors,
},
} as const;
// Define default namespace
export const defaultNS = 'common';
i18n
// Detect user language from localStorage, navigator, etc.
.use(LanguageDetector)
// Pass i18n instance to react-i18next
.use(initReactI18next)
// Initialize i18next
.init({
resources,
fallbackLng: 'en',
defaultNS,
// Language detection options
detection: {
// Order of language detection methods
order: ['localStorage', 'navigator', 'htmlTag'],
// Cache user language preference in localStorage
caches: ['localStorage'],
// Key name in localStorage
lookupLocalStorage: 'i18nextLng',
},
interpolation: {
// React already escapes values, so we don't need i18next to do it
escapeValue: false,
},
// React Suspense support (for future lazy loading)
react: {
useSuspense: false,
},
});
export default i18n;

View File

@@ -0,0 +1,68 @@
{
"pageTitle": "Admin Panel",
"managing": "Managing:",
"tabs": {
"projectSettings": "Project Settings",
"memberRoles": "Member Roles",
"relationshipTypes": "Relationship Types"
},
"backToDashboard": "Back to Dashboard",
"noProject": {
"title": "No Project Selected",
"message": "Please select a project from the dashboard first.",
"goToDashboard": "Go to Dashboard"
},
"projectSettings": {
"title": "Project Settings",
"projectName": "Project Name",
"description": "Description",
"saveButton": "Save Changes",
"successMessage": "Project updated successfully!",
"errorUpdating": "Failed to update project"
},
"memberRoles": {
"title": "Member Roles",
"loadingMembers": "Loading members...",
"noMembers": "No members found",
"tableHeaders": {
"user": "User",
"role": "Role",
"actions": "Actions"
},
"youBadge": "You",
"cannotDemoteSelf": "You cannot demote yourself. Ask another admin to change your role.",
"errorUpdating": "Failed to update member role"
},
"relationshipTypes": {
"title": "Relationship Types",
"addButton": "+ Add Type",
"loadingTypes": "Loading relationship types...",
"noTypes": "No relationship types defined yet. Create one to link requirements.",
"inverse": "Inverse:",
"editButton": "Edit",
"deleteButton": "Delete"
},
"createRelTypeModal": {
"title": "Create Relationship Type",
"typeName": "Type Name",
"typeNamePlaceholder": "e.g., Depends On",
"inverseName": "Inverse Name",
"inverseNamePlaceholder": "e.g., Depended By",
"inverseNameHint": "Optional. The name shown when viewing from the target requirement.",
"description": "Description",
"createButton": "Create",
"errorCreating": "Failed to create relationship type"
},
"editRelTypeModal": {
"title": "Edit Relationship Type",
"saveButton": "Save Changes",
"errorUpdating": "Failed to update relationship type"
},
"deleteRelTypeModal": {
"title": "Delete Relationship Type",
"confirmMessage": "Are you sure you want to delete the relationship type",
"warningMessage": "⚠️ This will also delete all requirement links using this type.",
"deleteButton": "Delete",
"errorDeleting": "Failed to delete relationship type"
}
}

View File

@@ -0,0 +1,49 @@
{
"save": "Save",
"saveChanges": "Save Changes",
"saving": "Saving...",
"cancel": "Cancel",
"delete": "Delete",
"deleting": "Deleting...",
"edit": "Edit",
"close": "Close",
"loading": "Loading...",
"retry": "Retry",
"search": "Search",
"clear": "Clear",
"filter": "Filter",
"create": "Create",
"creating": "Creating...",
"add": "Add",
"remove": "Remove",
"yes": "Yes",
"no": "No",
"none": "None",
"unknown": "Unknown",
"noResults": "No results found",
"required": "Required",
"optional": "Optional",
"version": "Version",
"priority": "Priority",
"status": "Status",
"date": "Date",
"name": "Name",
"description": "Description",
"actions": "Actions",
"details": "Details",
"user": "User",
"role": "Role",
"by": "by",
"on": "on",
"more": "more",
"empty": "Empty",
"old": "Old",
"new": "New",
"total": "Total",
"all": "All",
"you": "You",
"projects": "Projects",
"project": "Project",
"selectLanguage": "Select language",
"logout": "Logout"
}

View File

@@ -0,0 +1,67 @@
{
"sidebar": {
"title": "Digital Twin",
"subtitle": "Requirements Tool",
"createRequirement": "Create Requirement",
"navigation": "Navigation",
"searchRequirements": "Search Requirements",
"myRequirements": "My Requirements",
"generateReport": "Generate Report",
"projectSummary": "Project Summary",
"total": "Total",
"validated": "Validated"
},
"header": {
"projects": "Projects",
"admin": "Admin"
},
"projectDropdown": {
"loading": "Loading...",
"noProjectSelected": "No project selected",
"noProjectsAvailable": "No projects available",
"createNewProject": "+ Create New Project"
},
"noProjectWarning": {
"title": "No Project Selected",
"messagePart1": "Please select a project from the dropdown above or",
"createProject": "create a new project",
"messagePart2": "to get started."
},
"quickFilters": {
"title": "Quick Search Filters",
"subtitle": "Click a category to filter requirements",
"noGroupsFound": "No groups found",
"total": "total"
},
"needsAttention": {
"title": "Needs Attention",
"subtitle": "Requirements with denied or partial validation",
"viewAll": "View All",
"moreRequirements": "more requirements need attention",
"by": "by"
},
"needsRevalidation": {
"title": "Needs Revalidation",
"subtitle": "Requirements updated since last validation",
"viewAll": "View All",
"moreRequirements": "more requirements need revalidation",
"versionBehind": "version behind",
"versionsBehind": "versions behind",
"lastValidatedBy": "Last validated by",
"by": "by"
},
"allClear": {
"title": "All Clear!",
"message": "No requirements need attention. All validations are either approved or pending review."
},
"createProject": {
"title": "Create New Project",
"projectName": "Project Name",
"projectNamePlaceholder": "Enter project name",
"description": "Description",
"descriptionPlaceholder": "Enter project description (optional)",
"createButton": "Create Project",
"creating": "Creating...",
"errorCreating": "Failed to create project. Please try again."
}
}

View File

@@ -0,0 +1,11 @@
{
"generic": "An error occurred. Please try again.",
"loadFailed": "Failed to load data. Please try again.",
"saveFailed": "Failed to save. Please try again.",
"deleteFailed": "Failed to delete. Please try again.",
"createFailed": "Failed to create. Please try again.",
"unauthorized": "You are not authorized to perform this action.",
"notFound": "The requested resource was not found.",
"networkError": "Network error. Please check your connection.",
"sessionExpired": "Your session has expired. Please log in again."
}

View File

@@ -0,0 +1,5 @@
{
"title": "Requirements Periodic Table",
"subtitle": "Manage and track your project requirements.",
"goToDashboard": "Go to Dashboard"
}

View File

@@ -0,0 +1,7 @@
{
"brand": "Requirements Periodic Table",
"dashboard": "Dashboard",
"hello": "Hello",
"logout": "Logout",
"login": "Login"
}

View File

@@ -0,0 +1,155 @@
{
"pageTitle": "Digital Twin Requirements Tool",
"breadcrumb": {
"search": "Search",
"details": "Details"
},
"loadingRequirement": "Loading requirement...",
"requirementNotFound": "Requirement not found",
"noRequirementId": "No requirement ID provided",
"backToRequirements": "Back to Requirements",
"errorLoading": "Failed to load requirement. Please try again.",
"draft": {
"badge": "Draft - Not Finalized",
"title": "Draft Requirement",
"message": "This requirement is still in draft status and is not finalized. It may be subject to changes."
},
"tabs": {
"description": "Description",
"relationships": "Relationships",
"acceptanceCriteria": "Acceptance Criteria",
"sharedComments": "Shared Comments",
"validate": "Validate",
"history": "History"
},
"description": {
"title": "Description",
"version": "Version:",
"status": "Status:",
"validationStatus": "Validation Status:",
"author": "Author:",
"lastEditedBy": "Last Edited By:",
"created": "Created:",
"lastUpdated": "Last Updated:",
"noDescription": "No description provided.",
"editButton": "Edit"
},
"relationships": {
"title": "Relationships",
"addButton": "Add Relationship",
"noTypesWarning": "No relationship types have been defined for this project. Contact an administrator to set up relationship types.",
"loadingRelationships": "Loading relationships...",
"noRelationships": "No relationships defined yet.",
"tableHeaders": {
"direction": "Direction",
"type": "Type",
"linkedRequirement": "Linked Requirement",
"createdBy": "Created By",
"date": "Date",
"actions": "Actions"
},
"outgoing": "→ Outgoing",
"incoming": "← Incoming"
},
"acceptanceCriteria": {
"title": "Acceptance Criteria",
"noCriteria": "No acceptance criteria defined yet.",
"addButton": "Add Criterion"
},
"comments": {
"title": "Shared Comments",
"placeholder": "Add a comment...",
"postButton": "Post Comment",
"posting": "Posting...",
"noComments": "No comments yet. Be the first to comment!",
"loadingComments": "Loading comments...",
"reply": "Reply",
"cancelReply": "Cancel Reply",
"replyPlaceholder": "Write a reply...",
"postReply": "Post Reply",
"errorPosting": "Failed to post comment. Please try again.",
"errorPostingReply": "Failed to post reply. Please try again.",
"roles": {
"editor": "Editor",
"auditor": "Auditor",
"admin": "Project Admin",
"user": "User"
}
},
"validate": {
"title": "Validate Requirement",
"currentStatus": "Current Status:",
"requirementVersion": "Requirement Version:",
"lastValidatedBy": "Last validated by",
"staleWarning": "This requirement was modified after the last validation (validated at version {{validationVersion}}, current version {{currentVersion}}).",
"submitTitle": "Submit Validation",
"statusLabel": "Validation Status",
"selectStatus": "Select a status...",
"commentLabel": "Comment",
"commentPlaceholder": "Add a comment explaining your decision (optional but recommended)",
"submitButton": "Submit Validation",
"submitting": "Submitting...",
"errorSubmitting": "Failed to submit validation. Please try again.",
"historyTitle": "Validation History",
"loadingHistory": "Loading history...",
"noHistory": "No validation history yet.",
"tableHeaders": {
"date": "Date",
"status": "Status",
"version": "Version",
"validator": "Validator",
"comment": "Comment"
},
"noComment": "No comment"
},
"history": {
"title": "Version History",
"originalAuthor": "Original Author:",
"loadingHistory": "Loading history...",
"noHistory": "No history yet. History is recorded when the requirement is edited or relationships change.",
"requirementEdited": "Requirement edited",
"linkCreated": "Link Created",
"linkRemoved": "Link Removed",
"groupAdded": "Group Added",
"groupRemoved": "Group Removed",
"noChangesDetected": "No visible changes detected (may be a group-only change).",
"showLess": "Show less",
"showFullDiff": "Show full diff",
"deletedRequirement": "Deleted Requirement",
"unknownGroup": "Unknown Group"
},
"addRelationshipModal": {
"title": "Add Relationship",
"relationshipType": "Relationship Type",
"selectType": "Select a relationship type...",
"targetRequirement": "Target Requirement",
"searchPlaceholder": "Search by tag code or name...",
"selected": "Selected:",
"createButton": "Create Relationship",
"errorCreating": "Failed to create link. Please try again."
},
"editModal": {
"title": "Edit Requirement",
"loadingOptions": "Loading options...",
"name": "Name",
"tag": "Tag",
"selectTag": "Select a tag...",
"priority": "Priority",
"noPriority": "No priority",
"status": "Status",
"draftNote": "Draft requirements are not finalized and marked with a visual indicator.",
"description": "Description",
"groups": "Groups",
"noGroupsAvailable": "No groups available",
"errorSaving": "Failed to save changes. Please try again.",
"nameTagRequired": "Name and Tag are required"
},
"deleteModal": {
"deleteComment": "Delete Comment",
"deleteReply": "Delete Reply",
"deleteRelationship": "Delete Relationship",
"confirmDeleteComment": "Are you sure you want to delete this comment? This will also hide all replies.",
"confirmDeleteReply": "Are you sure you want to delete this reply?",
"confirmDeleteRelationship": "Are you sure you want to delete this relationship? This action cannot be undone."
}
}

View File

@@ -0,0 +1,87 @@
{
"header": {
"title": "Digital Twin Requirements Tool"
},
"breadcrumb": {
"projects": "Projects",
"searchRequirements": "Search Requirements"
},
"noProjectSelected": {
"title": "No Project Selected",
"message": "Please select a project from the dashboard to view requirements.",
"goToDashboard": "Go to Dashboard"
},
"loadingRequirements": "Loading requirements...",
"newRequirement": "New Requirement",
"deleted": "Deleted",
"searchPlaceholder": "Search for a requirement tag or title",
"noRequirementsFound": "No requirements found matching your criteria.",
"filters": {
"filterGroup": "Filter Group",
"filterValidationStatus": "Filter Validation Status",
"showingNeedsRevalidation": "Showing: Needs Revalidation",
"showingNeedsAttention": "Showing: Needs Attention",
"needsRevalidation": "Needs Revalidation",
"statuses": {
"approved": "Approved",
"denied": "Denied",
"partiallyApproved": "Partially Approved",
"notValidated": "Not Validated"
}
},
"orderBy": {
"label": "Order by",
"date": "Date",
"priority": "Priority",
"name": "Name"
},
"caption": {
"title": "Tag Caption",
"viewCaptions": "View tag captions"
},
"requirement": {
"draft": "Draft",
"draftTooltip": "This requirement is still in draft and not finalized",
"noGroups": "No groups",
"more": "more",
"stale": "Stale",
"staleTooltip": "Requirement was modified after validation",
"by": "by",
"priority": "Priority",
"version": "Version"
},
"deletedPanel": {
"title": "Deleted Requirements",
"noDeleted": "No deleted requirements found.",
"deletedWillAppear": "Deleted requirements will appear here.",
"unnamed": "Unnamed Requirement",
"originalId": "Original ID",
"deletedAt": "Deleted",
"deletedBy": "Deleted by",
"footerNote": "Deleted requirements are preserved in history for auditing purposes."
},
"deleteModal": {
"title": "Delete Requirement",
"confirmMessage": "Are you sure you want to delete the requirement",
"explanation": "This action will move the requirement to the deleted items. You can view deleted requirements from the \"Deleted\" panel.",
"deleting": "Deleting..."
},
"createModal": {
"title": "New Requirement",
"tag": "Tag",
"selectTag": "Select a tag...",
"name": "Name",
"namePlaceholder": "Enter requirement name",
"description": "Description",
"descriptionPlaceholder": "Enter requirement description (optional)",
"priority": "Priority",
"selectPriority": "Select a priority (optional)...",
"groups": "Groups",
"createButton": "Create Requirement",
"creating": "Creating...",
"errorCreating": "Failed to create requirement. Please try again.",
"noProjectSelected": "No project selected",
"nameRequired": "Requirement name is required",
"tagRequired": "Please select a tag"
}
}

View File

@@ -0,0 +1,7 @@
{
"approved": "Approved",
"denied": "Denied",
"partial": "Partial",
"partiallyApproved": "Partially Approved",
"notValidated": "Not Validated"
}

View File

@@ -0,0 +1,68 @@
{
"pageTitle": "Painel de Administração",
"managing": "Gerenciando:",
"tabs": {
"projectSettings": "Configurações do Projeto",
"memberRoles": "Funções dos Membros",
"relationshipTypes": "Tipos de Relacionamento"
},
"backToDashboard": "Voltar ao Painel",
"noProject": {
"title": "Nenhum Projeto Selecionado",
"message": "Por favor, selecione um projeto no painel primeiro.",
"goToDashboard": "Ir para o Painel"
},
"projectSettings": {
"title": "Configurações do Projeto",
"projectName": "Nome do Projeto",
"description": "Descrição",
"saveButton": "Salvar Alterações",
"successMessage": "Projeto atualizado com sucesso!",
"errorUpdating": "Falha ao atualizar projeto"
},
"memberRoles": {
"title": "Funções dos Membros",
"loadingMembers": "Carregando membros...",
"noMembers": "Nenhum membro encontrado",
"tableHeaders": {
"user": "Usuário",
"role": "Função",
"actions": "Ações"
},
"youBadge": "Você",
"cannotDemoteSelf": "Você não pode rebaixar a si mesmo. Peça a outro administrador para alterar sua função.",
"errorUpdating": "Falha ao atualizar função do membro"
},
"relationshipTypes": {
"title": "Tipos de Relacionamento",
"addButton": "+ Adicionar Tipo",
"loadingTypes": "Carregando tipos de relacionamento...",
"noTypes": "Nenhum tipo de relacionamento definido ainda. Crie um para vincular requisitos.",
"inverse": "Inverso:",
"editButton": "Editar",
"deleteButton": "Excluir"
},
"createRelTypeModal": {
"title": "Criar Tipo de Relacionamento",
"typeName": "Nome do Tipo",
"typeNamePlaceholder": "ex.: Depende De",
"inverseName": "Nome Inverso",
"inverseNamePlaceholder": "ex.: É Dependência De",
"inverseNameHint": "Opcional. O nome mostrado ao visualizar do requisito de destino.",
"description": "Descrição",
"createButton": "Criar",
"errorCreating": "Falha ao criar tipo de relacionamento"
},
"editRelTypeModal": {
"title": "Editar Tipo de Relacionamento",
"saveButton": "Salvar Alterações",
"errorUpdating": "Falha ao atualizar tipo de relacionamento"
},
"deleteRelTypeModal": {
"title": "Excluir Tipo de Relacionamento",
"confirmMessage": "Tem certeza de que deseja excluir o tipo de relacionamento",
"warningMessage": "⚠️ Isso também excluirá todos os links de requisitos usando este tipo.",
"deleteButton": "Excluir",
"errorDeleting": "Falha ao excluir tipo de relacionamento"
}
}

View File

@@ -0,0 +1,49 @@
{
"save": "Salvar",
"saveChanges": "Salvar Alterações",
"saving": "Salvando...",
"cancel": "Cancelar",
"delete": "Excluir",
"deleting": "Excluindo...",
"edit": "Editar",
"close": "Fechar",
"loading": "Carregando...",
"retry": "Tentar Novamente",
"search": "Buscar",
"clear": "Limpar",
"filter": "Filtrar",
"create": "Criar",
"creating": "Criando...",
"add": "Adicionar",
"remove": "Remover",
"yes": "Sim",
"no": "Não",
"none": "Nenhum",
"unknown": "Desconhecido",
"noResults": "Nenhum resultado encontrado",
"required": "Obrigatório",
"optional": "Opcional",
"version": "Versão",
"priority": "Prioridade",
"status": "Status",
"date": "Data",
"name": "Nome",
"description": "Descrição",
"actions": "Ações",
"details": "Detalhes",
"user": "Usuário",
"role": "Função",
"by": "por",
"on": "em",
"more": "mais",
"empty": "Vazio",
"old": "Antigo",
"new": "Novo",
"total": "Total",
"all": "Todos",
"you": "Você",
"projects": "Projetos",
"project": "Projeto",
"selectLanguage": "Selecionar idioma",
"logout": "Sair"
}

View File

@@ -0,0 +1,67 @@
{
"sidebar": {
"title": "Digital Twin",
"subtitle": "Ferramenta de Requisitos",
"createRequirement": "Criar Requisito",
"navigation": "Navegação",
"searchRequirements": "Buscar Requisitos",
"myRequirements": "Meus Requisitos",
"generateReport": "Gerar Relatório",
"projectSummary": "Resumo do Projeto",
"total": "Total",
"validated": "Validados"
},
"header": {
"projects": "Projetos",
"admin": "Admin"
},
"projectDropdown": {
"loading": "Carregando...",
"noProjectSelected": "Nenhum projeto selecionado",
"noProjectsAvailable": "Nenhum projeto disponível",
"createNewProject": "+ Criar Novo Projeto"
},
"noProjectWarning": {
"title": "Nenhum Projeto Selecionado",
"messagePart1": "Por favor, selecione um projeto no menu acima ou",
"createProject": "crie um novo projeto",
"messagePart2": "para começar."
},
"quickFilters": {
"title": "Filtros Rápidos de Busca",
"subtitle": "Clique em uma categoria para filtrar requisitos",
"noGroupsFound": "Nenhum grupo encontrado",
"total": "total"
},
"needsAttention": {
"title": "Precisa de Atenção",
"subtitle": "Requisitos com validação negada ou parcial",
"viewAll": "Ver Todos",
"moreRequirements": "mais requisitos precisam de atenção",
"by": "por"
},
"needsRevalidation": {
"title": "Precisa de Revalidação",
"subtitle": "Requisitos atualizados desde a última validação",
"viewAll": "Ver Todos",
"moreRequirements": "mais requisitos precisam de revalidação",
"versionBehind": "versão atrás",
"versionsBehind": "versões atrás",
"lastValidatedBy": "Última validação por",
"by": "por"
},
"allClear": {
"title": "Tudo Certo!",
"message": "Nenhum requisito precisa de atenção. Todas as validações estão aprovadas ou pendentes de revisão."
},
"createProject": {
"title": "Criar Novo Projeto",
"projectName": "Nome do Projeto",
"projectNamePlaceholder": "Digite o nome do projeto",
"description": "Descrição",
"descriptionPlaceholder": "Digite a descrição do projeto (opcional)",
"createButton": "Criar Projeto",
"creating": "Criando...",
"errorCreating": "Falha ao criar projeto. Por favor, tente novamente."
}
}

View File

@@ -0,0 +1,11 @@
{
"generic": "Ocorreu um erro. Por favor, tente novamente.",
"loadFailed": "Falha ao carregar dados. Por favor, tente novamente.",
"saveFailed": "Falha ao salvar. Por favor, tente novamente.",
"deleteFailed": "Falha ao excluir. Por favor, tente novamente.",
"createFailed": "Falha ao criar. Por favor, tente novamente.",
"unauthorized": "Você não está autorizado a realizar esta ação.",
"notFound": "O recurso solicitado não foi encontrado.",
"networkError": "Erro de rede. Por favor, verifique sua conexão.",
"sessionExpired": "Sua sessão expirou. Por favor, faça login novamente."
}

View File

@@ -0,0 +1,5 @@
{
"title": "Tabela Periódica de Requisitos",
"subtitle": "Gerencie e acompanhe os requisitos do seu projeto.",
"goToDashboard": "Ir para o Painel"
}

View File

@@ -0,0 +1,7 @@
{
"brand": "Tabela Periódica de Requisitos",
"dashboard": "Painel",
"hello": "Olá",
"logout": "Sair",
"login": "Entrar"
}

View File

@@ -0,0 +1,155 @@
{
"pageTitle": "Ferramenta de Requisitos Digital Twin",
"breadcrumb": {
"search": "Buscar",
"details": "Detalhes"
},
"loadingRequirement": "Carregando requisito...",
"requirementNotFound": "Requisito não encontrado",
"noRequirementId": "Nenhum ID de requisito fornecido",
"backToRequirements": "Voltar aos Requisitos",
"errorLoading": "Falha ao carregar requisito. Por favor, tente novamente.",
"draft": {
"badge": "Rascunho - Não Finalizado",
"title": "Requisito em Rascunho",
"message": "Este requisito ainda está em status de rascunho e não foi finalizado. Pode estar sujeito a alterações."
},
"tabs": {
"description": "Descrição",
"relationships": "Relacionamentos",
"acceptanceCriteria": "Critérios de Aceitação",
"sharedComments": "Comentários Compartilhados",
"validate": "Validar",
"history": "Histórico"
},
"description": {
"title": "Descrição",
"version": "Versão:",
"status": "Status:",
"validationStatus": "Status de Validação:",
"author": "Autor:",
"lastEditedBy": "Última Edição Por:",
"created": "Criado:",
"lastUpdated": "Última Atualização:",
"noDescription": "Nenhuma descrição fornecida.",
"editButton": "Editar"
},
"relationships": {
"title": "Relacionamentos",
"addButton": "Adicionar Relacionamento",
"noTypesWarning": "Nenhum tipo de relacionamento foi definido para este projeto. Contate um administrador para configurar os tipos de relacionamento.",
"loadingRelationships": "Carregando relacionamentos...",
"noRelationships": "Nenhum relacionamento definido ainda.",
"tableHeaders": {
"direction": "Direção",
"type": "Tipo",
"linkedRequirement": "Requisito Vinculado",
"createdBy": "Criado Por",
"date": "Data",
"actions": "Ações"
},
"outgoing": "→ Saída",
"incoming": "← Entrada"
},
"acceptanceCriteria": {
"title": "Critérios de Aceitação",
"noCriteria": "Nenhum critério de aceitação definido ainda.",
"addButton": "Adicionar Critério"
},
"comments": {
"title": "Comentários Compartilhados",
"placeholder": "Adicionar um comentário...",
"postButton": "Publicar Comentário",
"posting": "Publicando...",
"noComments": "Nenhum comentário ainda. Seja o primeiro a comentar!",
"loadingComments": "Carregando comentários...",
"reply": "Responder",
"cancelReply": "Cancelar Resposta",
"replyPlaceholder": "Escreva uma resposta...",
"postReply": "Publicar Resposta",
"errorPosting": "Falha ao publicar comentário. Por favor, tente novamente.",
"errorPostingReply": "Falha ao publicar resposta. Por favor, tente novamente.",
"roles": {
"editor": "Editor",
"auditor": "Auditor",
"admin": "Admin do Projeto",
"user": "Usuário"
}
},
"validate": {
"title": "Validar Requisito",
"currentStatus": "Status Atual:",
"requirementVersion": "Versão do Requisito:",
"lastValidatedBy": "Última validação por",
"staleWarning": "Este requisito foi modificado após a última validação (validado na versão {{validationVersion}}, versão atual {{currentVersion}}).",
"submitTitle": "Enviar Validação",
"statusLabel": "Status de Validação",
"selectStatus": "Selecione um status...",
"commentLabel": "Comentário",
"commentPlaceholder": "Adicione um comentário explicando sua decisão (opcional, mas recomendado)",
"submitButton": "Enviar Validação",
"submitting": "Enviando...",
"errorSubmitting": "Falha ao enviar validação. Por favor, tente novamente.",
"historyTitle": "Histórico de Validação",
"loadingHistory": "Carregando histórico...",
"noHistory": "Nenhum histórico de validação ainda.",
"tableHeaders": {
"date": "Data",
"status": "Status",
"version": "Versão",
"validator": "Validador",
"comment": "Comentário"
},
"noComment": "Sem comentário"
},
"history": {
"title": "Histórico de Versões",
"originalAuthor": "Autor Original:",
"loadingHistory": "Carregando histórico...",
"noHistory": "Nenhum histórico ainda. O histórico é registrado quando o requisito é editado ou os relacionamentos mudam.",
"requirementEdited": "Requisito editado",
"linkCreated": "Link Criado",
"linkRemoved": "Link Removido",
"groupAdded": "Grupo Adicionado",
"groupRemoved": "Grupo Removido",
"noChangesDetected": "Nenhuma alteração visível detectada (pode ser uma alteração apenas de grupo).",
"showLess": "Mostrar menos",
"showFullDiff": "Mostrar diferença completa",
"deletedRequirement": "Requisito Excluído",
"unknownGroup": "Grupo Desconhecido"
},
"addRelationshipModal": {
"title": "Adicionar Relacionamento",
"relationshipType": "Tipo de Relacionamento",
"selectType": "Selecione um tipo de relacionamento...",
"targetRequirement": "Requisito de Destino",
"searchPlaceholder": "Buscar por código de tag ou nome...",
"selected": "Selecionado:",
"createButton": "Criar Relacionamento",
"errorCreating": "Falha ao criar link. Por favor, tente novamente."
},
"editModal": {
"title": "Editar Requisito",
"loadingOptions": "Carregando opções...",
"name": "Nome",
"tag": "Tag",
"selectTag": "Selecione uma tag...",
"priority": "Prioridade",
"noPriority": "Sem prioridade",
"status": "Status",
"draftNote": "Requisitos em rascunho não estão finalizados e são marcados com um indicador visual.",
"description": "Descrição",
"groups": "Grupos",
"noGroupsAvailable": "Nenhum grupo disponível",
"errorSaving": "Falha ao salvar alterações. Por favor, tente novamente.",
"nameTagRequired": "Nome e Tag são obrigatórios"
},
"deleteModal": {
"deleteComment": "Excluir Comentário",
"deleteReply": "Excluir Resposta",
"deleteRelationship": "Excluir Relacionamento",
"confirmDeleteComment": "Tem certeza de que deseja excluir este comentário? Isso também ocultará todas as respostas.",
"confirmDeleteReply": "Tem certeza de que deseja excluir esta resposta?",
"confirmDeleteRelationship": "Tem certeza de que deseja excluir este relacionamento? Esta ação não pode ser desfeita."
}
}

View File

@@ -0,0 +1,87 @@
{
"header": {
"title": "Ferramenta de Requisitos Digital Twin"
},
"breadcrumb": {
"projects": "Projetos",
"searchRequirements": "Buscar Requisitos"
},
"noProjectSelected": {
"title": "Nenhum Projeto Selecionado",
"message": "Por favor, selecione um projeto no painel para visualizar os requisitos.",
"goToDashboard": "Ir para o Painel"
},
"loadingRequirements": "Carregando requisitos...",
"newRequirement": "Novo Requisito",
"deleted": "Excluídos",
"searchPlaceholder": "Buscar por código de tag ou título do requisito",
"noRequirementsFound": "Nenhum requisito encontrado com os critérios selecionados.",
"filters": {
"filterGroup": "Filtrar Grupo",
"filterValidationStatus": "Filtrar Status de Validação",
"showingNeedsRevalidation": "Mostrando: Precisa de Revalidação",
"showingNeedsAttention": "Mostrando: Precisa de Atenção",
"needsRevalidation": "Precisa de Revalidação",
"statuses": {
"approved": "Aprovado",
"denied": "Negado",
"partiallyApproved": "Parcialmente Aprovado",
"notValidated": "Não Validado"
}
},
"orderBy": {
"label": "Ordenar por",
"date": "Data",
"priority": "Prioridade",
"name": "Nome"
},
"caption": {
"title": "Legenda das Tags",
"viewCaptions": "Ver legendas das tags"
},
"requirement": {
"draft": "Rascunho",
"draftTooltip": "Este requisito ainda está em rascunho e não foi finalizado",
"noGroups": "Sem grupos",
"more": "mais",
"stale": "Desatualizado",
"staleTooltip": "O requisito foi modificado após a validação",
"by": "por",
"priority": "Prioridade",
"version": "Versão"
},
"deletedPanel": {
"title": "Requisitos Excluídos",
"noDeleted": "Nenhum requisito excluído encontrado.",
"deletedWillAppear": "Requisitos excluídos aparecerão aqui.",
"unnamed": "Requisito sem nome",
"originalId": "ID Original",
"deletedAt": "Excluído",
"deletedBy": "Excluído por",
"footerNote": "Requisitos excluídos são preservados no histórico para fins de auditoria."
},
"deleteModal": {
"title": "Excluir Requisito",
"confirmMessage": "Tem certeza de que deseja excluir o requisito",
"explanation": "Esta ação moverá o requisito para os itens excluídos. Você pode visualizar os requisitos excluídos no painel \"Excluídos\".",
"deleting": "Excluindo..."
},
"createModal": {
"title": "Novo Requisito",
"tag": "Tag",
"selectTag": "Selecione uma tag...",
"name": "Nome",
"namePlaceholder": "Digite o nome do requisito",
"description": "Descrição",
"descriptionPlaceholder": "Digite a descrição do requisito (opcional)",
"priority": "Prioridade",
"selectPriority": "Selecione uma prioridade (opcional)...",
"groups": "Grupos",
"createButton": "Criar Requisito",
"creating": "Criando...",
"errorCreating": "Falha ao criar requisito. Por favor, tente novamente.",
"noProjectSelected": "Nenhum projeto selecionado",
"nameRequired": "O nome do requisito é obrigatório",
"tagRequired": "Por favor, selecione uma tag"
}
}

View File

@@ -0,0 +1,7 @@
{
"approved": "Aprovado",
"denied": "Negado",
"partial": "Parcial",
"partiallyApproved": "Parcialmente Aprovado",
"notValidated": "Não Validado"
}

View File

@@ -5,6 +5,9 @@ import App from './App'
import { AuthProvider, ProjectProvider } from '@/context' import { AuthProvider, ProjectProvider } from '@/context'
import './index.css' import './index.css'
// Initialize i18n before rendering
import './i18n'
// Global fetch interceptor to handle 401 Unauthorized responses // Global fetch interceptor to handle 401 Unauthorized responses
// This intercepts all fetch calls and attempts silent refresh before redirecting // This intercepts all fetch calls and attempts silent refresh before redirecting
const originalFetch = window.fetch const originalFetch = window.fetch

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth, useProject } from '@/hooks' import { useAuth, useProject } from '@/hooks'
import { import {
projectService, projectService,
@@ -13,6 +14,8 @@ import {
type TabType = 'project' | 'members' | 'relationships' type TabType = 'project' | 'members' | 'relationships'
export default function AdminPage() { export default function AdminPage() {
const { t } = useTranslation('admin')
const { t: tCommon } = useTranslation('common')
const { user } = useAuth() const { user } = useAuth()
const { currentProject, setCurrentProject } = useProject() const { currentProject, setCurrentProject } = useProject()
const navigate = useNavigate() const navigate = useNavigate()
@@ -291,13 +294,13 @@ export default function AdminPage() {
return ( return (
<div className="min-h-screen bg-white flex items-center justify-center"> <div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<h2 className="text-xl font-semibold text-gray-700 mb-2">No Project Selected</h2> <h2 className="text-xl font-semibold text-gray-700 mb-2">{t('noProject.title')}</h2>
<p className="text-gray-500 mb-4">Please select a project from the dashboard first.</p> <p className="text-gray-500 mb-4">{t('noProject.message')}</p>
<button <button
onClick={() => navigate('/dashboard')} onClick={() => navigate('/dashboard')}
className="px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700" className="px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700"
> >
Go to Dashboard {t('noProject.goToDashboard')}
</button> </button>
</div> </div>
</div> </div>
@@ -308,9 +311,9 @@ export default function AdminPage() {
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
{/* Header */} {/* Header */}
<header className="py-6 text-center border-b border-gray-200"> <header className="py-6 text-center border-b border-gray-200">
<h1 className="text-3xl font-semibold text-teal-700">Admin Panel</h1> <h1 className="text-3xl font-semibold text-teal-700">{t('pageTitle')}</h1>
<p className="text-gray-500 mt-1"> <p className="text-gray-500 mt-1">
Managing: <span className="font-medium">{currentProject.project_name}</span> {t('managing')} <span className="font-medium">{currentProject.project_name}</span>
</p> </p>
</header> </header>
@@ -326,7 +329,7 @@ export default function AdminPage() {
: 'border-transparent text-gray-500 hover:text-gray-700' : 'border-transparent text-gray-500 hover:text-gray-700'
}`} }`}
> >
Project Settings {t('tabs.projectSettings')}
</button> </button>
<button <button
onClick={() => setActiveTab('members')} onClick={() => setActiveTab('members')}
@@ -336,7 +339,7 @@ export default function AdminPage() {
: 'border-transparent text-gray-500 hover:text-gray-700' : 'border-transparent text-gray-500 hover:text-gray-700'
}`} }`}
> >
Member Roles {t('tabs.memberRoles')}
</button> </button>
<button <button
onClick={() => setActiveTab('relationships')} onClick={() => setActiveTab('relationships')}
@@ -346,7 +349,7 @@ export default function AdminPage() {
: 'border-transparent text-gray-500 hover:text-gray-700' : 'border-transparent text-gray-500 hover:text-gray-700'
}`} }`}
> >
Relationship Types {t('tabs.relationshipTypes')}
</button> </button>
</nav> </nav>
</div> </div>
@@ -361,7 +364,7 @@ export default function AdminPage() {
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg> </svg>
Back to Dashboard {t('backToDashboard')}
</button> </button>
</div> </div>
@@ -370,7 +373,7 @@ export default function AdminPage() {
{/* Project Settings Tab */} {/* Project Settings Tab */}
{activeTab === 'project' && ( {activeTab === 'project' && (
<div className="bg-white border border-gray-200 rounded-lg p-6"> <div className="bg-white border border-gray-200 rounded-lg p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-6">Project Settings</h2> <h2 className="text-xl font-semibold text-gray-800 mb-6">{t('projectSettings.title')}</h2>
<form onSubmit={handleProjectUpdate} className="space-y-6"> <form onSubmit={handleProjectUpdate} className="space-y-6">
{projectError && ( {projectError && (
@@ -387,7 +390,7 @@ export default function AdminPage() {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Project Name <span className="text-red-500">*</span> {t('projectSettings.projectName')} <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
@@ -400,7 +403,7 @@ export default function AdminPage() {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Description {t('projectSettings.description')}
</label> </label>
<textarea <textarea
value={projectDesc} value={projectDesc}
@@ -416,7 +419,7 @@ export default function AdminPage() {
disabled={projectLoading} disabled={projectLoading}
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50" className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50"
> >
{projectLoading ? 'Saving...' : 'Save Changes'} {projectLoading ? tCommon('saving') : t('projectSettings.saveButton')}
</button> </button>
</div> </div>
</form> </form>
@@ -426,7 +429,7 @@ export default function AdminPage() {
{/* Members Tab */} {/* Members Tab */}
{activeTab === 'members' && ( {activeTab === 'members' && (
<div className="bg-white border border-gray-200 rounded-lg p-6"> <div className="bg-white border border-gray-200 rounded-lg p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-6">Member Roles</h2> <h2 className="text-xl font-semibold text-gray-800 mb-6">{t('memberRoles.title')}</h2>
{membersError && ( {membersError && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm"> <div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
@@ -435,17 +438,17 @@ export default function AdminPage() {
)} )}
{membersLoading ? ( {membersLoading ? (
<div className="text-center py-8 text-gray-500">Loading members...</div> <div className="text-center py-8 text-gray-500">{t('memberRoles.loadingMembers')}</div>
) : members.length === 0 ? ( ) : members.length === 0 ? (
<div className="text-center py-8 text-gray-500">No members found</div> <div className="text-center py-8 text-gray-500">{t('memberRoles.noMembers')}</div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-gray-200"> <tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-medium text-gray-700">User</th> <th className="text-left py-3 px-4 font-medium text-gray-700">{t('memberRoles.tableHeaders.user')}</th>
<th className="text-left py-3 px-4 font-medium text-gray-700">Role</th> <th className="text-left py-3 px-4 font-medium text-gray-700">{t('memberRoles.tableHeaders.role')}</th>
<th className="text-left py-3 px-4 font-medium text-gray-700">Actions</th> <th className="text-left py-3 px-4 font-medium text-gray-700">{t('memberRoles.tableHeaders.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -459,7 +462,7 @@ export default function AdminPage() {
<span className="text-sm text-gray-900">{member.sub}</span> <span className="text-sm text-gray-900">{member.sub}</span>
{isCurrentUser && ( {isCurrentUser && (
<span className="text-xs bg-teal-100 text-teal-700 px-2 py-0.5 rounded"> <span className="text-xs bg-teal-100 text-teal-700 px-2 py-0.5 rounded">
You {t('memberRoles.youBadge')}
</span> </span>
)} )}
</div> </div>
@@ -485,7 +488,7 @@ export default function AdminPage() {
className={`px-3 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 ${ className={`px-3 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 ${
isCurrentUser && member.role_id === 3 ? 'bg-gray-100 cursor-not-allowed' : '' isCurrentUser && member.role_id === 3 ? 'bg-gray-100 cursor-not-allowed' : ''
}`} }`}
title={isCurrentUser && member.role_id === 3 ? 'You cannot demote yourself' : ''} title={isCurrentUser && member.role_id === 3 ? t('memberRoles.cannotDemoteSelf') : ''}
> >
{roles.map((role) => ( {roles.map((role) => (
<option key={role.id} value={role.id}> <option key={role.id} value={role.id}>
@@ -494,7 +497,7 @@ export default function AdminPage() {
))} ))}
</select> </select>
{updatingMember === member.id && ( {updatingMember === member.id && (
<span className="ml-2 text-xs text-gray-500">Saving...</span> <span className="ml-2 text-xs text-gray-500">{tCommon('saving')}</span>
)} )}
</td> </td>
</tr> </tr>
@@ -511,7 +514,7 @@ export default function AdminPage() {
{activeTab === 'relationships' && ( {activeTab === 'relationships' && (
<div className="bg-white border border-gray-200 rounded-lg p-6"> <div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-800">Relationship Types</h2> <h2 className="text-xl font-semibold text-gray-800">{t('relationshipTypes.title')}</h2>
<button <button
onClick={() => { onClick={() => {
resetRelTypeForm() resetRelTypeForm()
@@ -519,7 +522,7 @@ export default function AdminPage() {
}} }}
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700" className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700"
> >
+ Add Type {t('relationshipTypes.addButton')}
</button> </button>
</div> </div>
@@ -530,10 +533,10 @@ export default function AdminPage() {
)} )}
{relTypesLoading ? ( {relTypesLoading ? (
<div className="text-center py-8 text-gray-500">Loading relationship types...</div> <div className="text-center py-8 text-gray-500">{t('relationshipTypes.loadingTypes')}</div>
) : relationshipTypes.length === 0 ? ( ) : relationshipTypes.length === 0 ? (
<div className="text-center py-8 text-gray-500"> <div className="text-center py-8 text-gray-500">
No relationship types defined yet. Create one to link requirements. {t('relationshipTypes.noTypes')}
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@@ -546,7 +549,7 @@ export default function AdminPage() {
<div className="font-medium text-gray-900">{relType.type_name}</div> <div className="font-medium text-gray-900">{relType.type_name}</div>
{relType.inverse_type_name && ( {relType.inverse_type_name && (
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
Inverse: {relType.inverse_type_name} {t('relationshipTypes.inverse')} {relType.inverse_type_name}
</div> </div>
)} )}
{relType.type_description && ( {relType.type_description && (
@@ -558,13 +561,13 @@ export default function AdminPage() {
onClick={() => openEditModal(relType)} onClick={() => openEditModal(relType)}
className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-700 hover:bg-gray-100" className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-700 hover:bg-gray-100"
> >
Edit {t('relationshipTypes.editButton')}
</button> </button>
<button <button
onClick={() => openDeleteModal(relType)} onClick={() => openDeleteModal(relType)}
className="px-3 py-1.5 border border-red-300 rounded text-sm text-red-600 hover:bg-red-50" className="px-3 py-1.5 border border-red-300 rounded text-sm text-red-600 hover:bg-red-50"
> >
Delete {t('relationshipTypes.deleteButton')}
</button> </button>
</div> </div>
</div> </div>
@@ -580,7 +583,7 @@ export default function AdminPage() {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4"> <div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800">Create Relationship Type</h2> <h2 className="text-xl font-semibold text-gray-800">{t('createRelTypeModal.title')}</h2>
<button <button
onClick={() => { onClick={() => {
setShowCreateRelTypeModal(false) setShowCreateRelTypeModal(false)
@@ -604,13 +607,13 @@ export default function AdminPage() {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Type Name <span className="text-red-500">*</span> {t('createRelTypeModal.typeName')} <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
value={relTypeName} value={relTypeName}
onChange={(e) => setRelTypeName(e.target.value)} onChange={(e) => setRelTypeName(e.target.value)}
placeholder="e.g., Depends On" placeholder={t('createRelTypeModal.typeNamePlaceholder')}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500" className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
required required
/> />
@@ -618,23 +621,23 @@ export default function AdminPage() {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Inverse Name {t('createRelTypeModal.inverseName')}
</label> </label>
<input <input
type="text" type="text"
value={relTypeInverse} value={relTypeInverse}
onChange={(e) => setRelTypeInverse(e.target.value)} onChange={(e) => setRelTypeInverse(e.target.value)}
placeholder="e.g., Depended By" placeholder={t('createRelTypeModal.inverseNamePlaceholder')}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500" className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
/> />
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
Optional. The name shown when viewing from the target requirement. {t('createRelTypeModal.inverseNameHint')}
</p> </p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Description {t('createRelTypeModal.description')}
</label> </label>
<textarea <textarea
value={relTypeDesc} value={relTypeDesc}
@@ -655,14 +658,14 @@ export default function AdminPage() {
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100" className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
disabled={relTypeFormLoading} disabled={relTypeFormLoading}
> >
Cancel {tCommon('cancel')}
</button> </button>
<button <button
type="submit" type="submit"
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50" className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50"
disabled={relTypeFormLoading} disabled={relTypeFormLoading}
> >
{relTypeFormLoading ? 'Creating...' : 'Create'} {relTypeFormLoading ? tCommon('creating') : t('createRelTypeModal.createButton')}
</button> </button>
</div> </div>
</form> </form>
@@ -675,7 +678,7 @@ export default function AdminPage() {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4"> <div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800">Edit Relationship Type</h2> <h2 className="text-xl font-semibold text-gray-800">{t('editRelTypeModal.title')}</h2>
<button <button
onClick={() => { onClick={() => {
setShowEditRelTypeModal(false) setShowEditRelTypeModal(false)
@@ -700,7 +703,7 @@ export default function AdminPage() {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Type Name <span className="text-red-500">*</span> {t('createRelTypeModal.typeName')} <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
@@ -713,7 +716,7 @@ export default function AdminPage() {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Inverse Name {t('createRelTypeModal.inverseName')}
</label> </label>
<input <input
type="text" type="text"
@@ -725,7 +728,7 @@ export default function AdminPage() {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Description {t('createRelTypeModal.description')}
</label> </label>
<textarea <textarea
value={relTypeDesc} value={relTypeDesc}
@@ -747,14 +750,14 @@ export default function AdminPage() {
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100" className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
disabled={relTypeFormLoading} disabled={relTypeFormLoading}
> >
Cancel {tCommon('cancel')}
</button> </button>
<button <button
type="submit" type="submit"
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50" className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50"
disabled={relTypeFormLoading} disabled={relTypeFormLoading}
> >
{relTypeFormLoading ? 'Saving...' : 'Save Changes'} {relTypeFormLoading ? tCommon('saving') : t('editRelTypeModal.saveButton')}
</button> </button>
</div> </div>
</form> </form>
@@ -767,7 +770,7 @@ export default function AdminPage() {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4"> <div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="px-6 py-4 border-b border-gray-200"> <div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800">Delete Relationship Type</h2> <h2 className="text-xl font-semibold text-gray-800">{t('deleteRelTypeModal.title')}</h2>
</div> </div>
<div className="px-6 py-4"> <div className="px-6 py-4">
@@ -778,11 +781,11 @@ export default function AdminPage() {
)} )}
<p className="text-gray-700"> <p className="text-gray-700">
Are you sure you want to delete the relationship type{' '} {t('deleteRelTypeModal.confirmMessage')}{' '}
<span className="font-semibold">"{selectedRelType.type_name}"</span>? <span className="font-semibold">"{selectedRelType.type_name}"</span>?
</p> </p>
<p className="text-sm text-red-600 mt-2"> <p className="text-sm text-red-600 mt-2">
This will also delete all requirement links using this type. {t('deleteRelTypeModal.warningMessage')}
</p> </p>
</div> </div>
@@ -796,14 +799,14 @@ export default function AdminPage() {
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100" className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
disabled={relTypeFormLoading} disabled={relTypeFormLoading}
> >
Cancel {tCommon('cancel')}
</button> </button>
<button <button
onClick={handleDeleteRelType} onClick={handleDeleteRelType}
className="px-4 py-2 bg-red-600 text-white rounded text-sm font-medium hover:bg-red-700 disabled:opacity-50" className="px-4 py-2 bg-red-600 text-white rounded text-sm font-medium hover:bg-red-700 disabled:opacity-50"
disabled={relTypeFormLoading} disabled={relTypeFormLoading}
> >
{relTypeFormLoading ? 'Deleting...' : 'Delete'} {relTypeFormLoading ? tCommon('deleting') : t('deleteRelTypeModal.deleteButton')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,9 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useAuth, useProject } from '@/hooks' import { useAuth, useProject } from '@/hooks'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { groupService, requirementService, Group } from '@/services' import { groupService, requirementService, Group } from '@/services'
import { LanguageSelector } from '@/components'
import type { Requirement } from '@/services/requirementService' import type { Requirement } from '@/services/requirementService'
/** /**
@@ -73,6 +75,8 @@ export default function DashboardPage() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { projects, currentProject, setCurrentProject, isLoading: projectsLoading, createProject } = useProject() const { projects, currentProject, setCurrentProject, isLoading: projectsLoading, createProject } = useProject()
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useTranslation('dashboard')
const { t: tCommon } = useTranslation('common')
const [groups, setGroups] = useState<Group[]>([]) const [groups, setGroups] = useState<Group[]>([])
const [requirements, setRequirements] = useState<Requirement[]>([]) const [requirements, setRequirements] = useState<Requirement[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -235,9 +239,9 @@ export default function DashboardPage() {
{/* Sidebar Header */} {/* Sidebar Header */}
<div className="p-6 border-b border-slate-700"> <div className="p-6 border-b border-slate-700">
<h1 className="text-lg font-semibold text-teal-400"> <h1 className="text-lg font-semibold text-teal-400">
Digital Twin {t('sidebar.title')}
</h1> </h1>
<p className="text-sm text-slate-400">Requirements Tool</p> <p className="text-sm text-slate-400">{t('sidebar.subtitle')}</p>
</div> </div>
{/* Navigation */} {/* Navigation */}
@@ -250,11 +254,11 @@ export default function DashboardPage() {
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg> </svg>
Create Requirement {t('sidebar.createRequirement')}
</button> </button>
<div className="pt-4 pb-2"> <div className="pt-4 pb-2">
<p className="px-4 text-xs font-semibold text-slate-500 uppercase tracking-wider">Navigation</p> <p className="px-4 text-xs font-semibold text-slate-500 uppercase tracking-wider">{t('sidebar.navigation')}</p>
</div> </div>
{/* Search Requirements */} {/* Search Requirements */}
@@ -265,7 +269,7 @@ export default function DashboardPage() {
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
Search Requirements {t('sidebar.searchRequirements')}
</button> </button>
{/* My Requirements */} {/* My Requirements */}
@@ -276,7 +280,7 @@ export default function DashboardPage() {
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg> </svg>
My Requirements {t('sidebar.myRequirements')}
</button> </button>
{/* Reports */} {/* Reports */}
@@ -286,22 +290,22 @@ export default function DashboardPage() {
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg> </svg>
Generate Report {t('sidebar.generateReport')}
</button> </button>
</nav> </nav>
{/* Project Stats Summary at bottom */} {/* Project Stats Summary at bottom */}
{currentProject && !loading && ( {currentProject && !loading && (
<div className="absolute bottom-0 left-0 w-64 p-4 border-t border-slate-700 bg-slate-900"> <div className="absolute bottom-0 left-0 w-64 p-4 border-t border-slate-700 bg-slate-900">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Project Summary</p> <p className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">{t('sidebar.projectSummary')}</p>
<div className="grid grid-cols-2 gap-2 text-center"> <div className="grid grid-cols-2 gap-2 text-center">
<div className="bg-slate-800 rounded p-2"> <div className="bg-slate-800 rounded p-2">
<p className="text-xl font-bold text-white">{getTotalStats().total}</p> <p className="text-xl font-bold text-white">{getTotalStats().total}</p>
<p className="text-xs text-slate-400">Total</p> <p className="text-xs text-slate-400">{t('sidebar.total')}</p>
</div> </div>
<div className="bg-slate-800 rounded p-2"> <div className="bg-slate-800 rounded p-2">
<p className="text-xl font-bold text-green-400">{getTotalStats().validated}</p> <p className="text-xl font-bold text-green-400">{getTotalStats().validated}</p>
<p className="text-xs text-slate-400">Validated</p> <p className="text-xs text-slate-400">{t('sidebar.validated')}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -315,7 +319,7 @@ export default function DashboardPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{/* Breadcrumb with Project Dropdown */} {/* Breadcrumb with Project Dropdown */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Projects</span> <span className="text-sm text-gray-500">{t('header.projects')}</span>
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
@@ -327,7 +331,7 @@ export default function DashboardPage() {
className="flex items-center gap-2 text-sm font-semibold text-gray-900 hover:text-teal-700 focus:outline-none" className="flex items-center gap-2 text-sm font-semibold text-gray-900 hover:text-teal-700 focus:outline-none"
> >
{projectsLoading ? ( {projectsLoading ? (
<span className="text-gray-500">Loading...</span> <span className="text-gray-500">{tCommon('loading')}</span>
) : currentProject ? ( ) : currentProject ? (
<> <>
{currentProject.project_name} {currentProject.project_name}
@@ -336,7 +340,7 @@ export default function DashboardPage() {
</svg> </svg>
</> </>
) : ( ) : (
<span className="text-gray-500 italic">No project selected</span> <span className="text-gray-500 italic">{t('projectDropdown.noProjectSelected')}</span>
)} )}
</button> </button>
@@ -346,7 +350,7 @@ export default function DashboardPage() {
<div className="py-1"> <div className="py-1">
{projects.length === 0 ? ( {projects.length === 0 ? (
<div className="px-4 py-2 text-sm text-gray-500"> <div className="px-4 py-2 text-sm text-gray-500">
No projects available {t('projectDropdown.noProjectsAvailable')}
</div> </div>
) : ( ) : (
projects.map((project) => ( projects.map((project) => (
@@ -376,7 +380,7 @@ export default function DashboardPage() {
}} }}
className="w-full text-left px-4 py-2 text-sm text-teal-600 hover:bg-gray-100 font-medium" className="w-full text-left px-4 py-2 text-sm text-teal-600 hover:bg-gray-100 font-medium"
> >
+ Create New Project {t('projectDropdown.createNewProject')}
</button> </button>
</div> </div>
</div> </div>
@@ -386,14 +390,8 @@ export default function DashboardPage() {
{/* Right side utilities - grouped tighter */} {/* Right side utilities - grouped tighter */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Language Toggle */} {/* Language Selector */}
<div className="flex items-center gap-2 text-sm text-gray-500"> <LanguageSelector compact />
<span>EN</span>
<div className="relative inline-flex h-5 w-9 items-center rounded-full bg-gray-300 cursor-pointer">
<span className="inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow-sm translate-x-0.5" />
</div>
<span>PT</span>
</div>
{/* Divider */} {/* Divider */}
<div className="h-6 w-px bg-gray-300"></div> <div className="h-6 w-px bg-gray-300"></div>
@@ -408,7 +406,7 @@ export default function DashboardPage() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
Admin {t('header.admin')}
</button> </button>
)} )}
@@ -422,15 +420,15 @@ export default function DashboardPage() {
</div> </div>
<div className="text-sm"> <div className="text-sm">
<p className="font-medium text-gray-700"> <p className="font-medium text-gray-700">
{user?.full_name || user?.preferred_username || 'User'} {user?.full_name || user?.preferred_username || tCommon('user')}
</p> </p>
<p className="text-xs text-gray-500">{user?.role || 'user'}</p> <p className="text-xs text-gray-500">{user?.role || tCommon('user').toLowerCase()}</p>
</div> </div>
</div> </div>
<button <button
onClick={logout} onClick={logout}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors" className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
title="Logout" title={tCommon('logout')}
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
@@ -451,16 +449,16 @@ export default function DashboardPage() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg> </svg>
<div> <div>
<h3 className="font-semibold text-amber-800">No Project Selected</h3> <h3 className="font-semibold text-amber-800">{t('noProjectWarning.title')}</h3>
<p className="text-sm text-amber-700"> <p className="text-sm text-amber-700">
Please select a project from the dropdown above or{' '} {t('noProjectWarning.messagePart1')}{' '}
<button <button
onClick={() => setShowCreateProjectModal(true)} onClick={() => setShowCreateProjectModal(true)}
className="underline font-medium hover:text-amber-900" className="underline font-medium hover:text-amber-900"
> >
create a new project {t('noProjectWarning.createProject')}
</button> </button>
{' '}to get started. {' '}{t('noProjectWarning.messagePart2')}
</p> </p>
</div> </div>
</div> </div>
@@ -471,10 +469,10 @@ export default function DashboardPage() {
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-800"> <h2 className="text-xl font-semibold text-gray-800">
Quick Search Filters {t('quickFilters.title')}
</h2> </h2>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Click a category to filter requirements {t('quickFilters.subtitle')}
</p> </p>
</div> </div>
@@ -483,7 +481,7 @@ export default function DashboardPage() {
<div className="flex justify-center items-center min-h-[200px]"> <div className="flex justify-center items-center min-h-[200px]">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-teal-600 mx-auto"></div> <div className="animate-spin rounded-full h-10 w-10 border-b-2 border-teal-600 mx-auto"></div>
<p className="mt-3 text-gray-500">Loading...</p> <p className="mt-3 text-gray-500">{tCommon('loading')}</p>
</div> </div>
</div> </div>
)} )}
@@ -556,7 +554,7 @@ export default function DashboardPage() {
<span <span
className={`ml-auto text-xs ${isLightText ? 'text-white/70' : 'text-black/50'}`} className={`ml-auto text-xs ${isLightText ? 'text-white/70' : 'text-black/50'}`}
> >
{stats.total} total {stats.total} {t('quickFilters.total')}
</span> </span>
</div> </div>
)} )}
@@ -569,7 +567,7 @@ export default function DashboardPage() {
{/* Empty State */} {/* Empty State */}
{!loading && !error && groups.length === 0 && ( {!loading && !error && groups.length === 0 && (
<div className="flex justify-center items-center min-h-[200px]"> <div className="flex justify-center items-center min-h-[200px]">
<div className="text-gray-500">No groups found</div> <div className="text-gray-500">{t('quickFilters.noGroupsFound')}</div>
</div> </div>
)} )}
</div> </div>
@@ -586,10 +584,10 @@ export default function DashboardPage() {
</div> </div>
<div> <div>
<h2 className="text-lg font-semibold text-gray-800"> <h2 className="text-lg font-semibold text-gray-800">
Needs Attention {t('needsAttention.title')}
</h2> </h2>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Requirements with denied or partial validation {t('needsAttention.subtitle')}
</p> </p>
</div> </div>
</div> </div>
@@ -597,7 +595,7 @@ export default function DashboardPage() {
onClick={handleNeedsAttentionClick} onClick={handleNeedsAttentionClick}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg transition-colors" className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg transition-colors"
> >
View All ({requirementsNeedingAttention.length}) {t('needsAttention.viewAll')} ({requirementsNeedingAttention.length})
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
@@ -653,7 +651,7 @@ export default function DashboardPage() {
onClick={handleNeedsAttentionClick} onClick={handleNeedsAttentionClick}
className="text-sm text-amber-600 hover:text-amber-700 font-medium" className="text-sm text-amber-600 hover:text-amber-700 font-medium"
> >
+ {requirementsNeedingAttention.length - 6} more requirements need attention + {requirementsNeedingAttention.length - 6} {t('needsAttention.moreRequirements')}
</button> </button>
</div> </div>
)} )}
@@ -672,10 +670,10 @@ export default function DashboardPage() {
</div> </div>
<div> <div>
<h2 className="text-lg font-semibold text-gray-800"> <h2 className="text-lg font-semibold text-gray-800">
Needs Revalidation {t('needsRevalidation.title')}
</h2> </h2>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Requirements updated since last validation {t('needsRevalidation.subtitle')}
</p> </p>
</div> </div>
</div> </div>
@@ -683,7 +681,7 @@ export default function DashboardPage() {
onClick={handleNeedsRevalidationClick} onClick={handleNeedsRevalidationClick}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors" className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
> >
View All ({requirementsNeedingRevalidation.length}) {t('needsRevalidation.viewAll')} ({requirementsNeedingRevalidation.length})
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
@@ -706,7 +704,7 @@ export default function DashboardPage() {
{req.tag.tag_code} {req.tag.tag_code}
</span> </span>
<span className="text-xs font-medium px-2 py-0.5 rounded bg-blue-100 text-blue-700"> <span className="text-xs font-medium px-2 py-0.5 rounded bg-blue-100 text-blue-700">
{versionDiff} version{versionDiff > 1 ? 's' : ''} behind {versionDiff} {versionDiff > 1 ? t('needsRevalidation.versionsBehind') : t('needsRevalidation.versionBehind')}
</span> </span>
</div> </div>
<h4 className="font-medium text-gray-800 text-sm line-clamp-2 mb-2"> <h4 className="font-medium text-gray-800 text-sm line-clamp-2 mb-2">
@@ -717,8 +715,8 @@ export default function DashboardPage() {
v{req.validation_version} v{req.version} v{req.validation_version} v{req.version}
</span> </span>
{req.validated_by && ( {req.validated_by && (
<span className="truncate max-w-[120px]" title={`Last validated by ${req.validated_by}`}> <span className="truncate max-w-[120px]" title={`${t('needsRevalidation.lastValidatedBy')} ${req.validated_by}`}>
by {req.validated_by} {t('needsRevalidation.by')} {req.validated_by}
</span> </span>
)} )}
</div> </div>
@@ -734,7 +732,7 @@ export default function DashboardPage() {
onClick={handleNeedsRevalidationClick} onClick={handleNeedsRevalidationClick}
className="text-sm text-blue-600 hover:text-blue-700 font-medium" className="text-sm text-blue-600 hover:text-blue-700 font-medium"
> >
+ {requirementsNeedingRevalidation.length - 6} more requirements need revalidation + {requirementsNeedingRevalidation.length - 6} {t('needsRevalidation.moreRequirements')}
</button> </button>
</div> </div>
)} )}
@@ -751,9 +749,9 @@ export default function DashboardPage() {
</svg> </svg>
</div> </div>
<div> <div>
<h3 className="font-semibold text-green-800">All Clear!</h3> <h3 className="font-semibold text-green-800">{t('allClear.title')}</h3>
<p className="text-sm text-green-600"> <p className="text-sm text-green-600">
No requirements need attention. All validations are either approved or pending review. {t('allClear.message')}
</p> </p>
</div> </div>
</div> </div>
@@ -776,7 +774,7 @@ export default function DashboardPage() {
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4"> <div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
{/* Modal Header */} {/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800">Create New Project</h2> <h2 className="text-xl font-semibold text-gray-800">{t('createProject.title')}</h2>
<button <button
onClick={() => { onClick={() => {
setShowCreateProjectModal(false) setShowCreateProjectModal(false)
@@ -805,13 +803,13 @@ export default function DashboardPage() {
{/* Project Name */} {/* Project Name */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Project Name <span className="text-red-500">*</span> {t('createProject.projectName')} <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
value={newProjectName} value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)} onChange={(e) => setNewProjectName(e.target.value)}
placeholder="Enter project name" placeholder={t('createProject.projectNamePlaceholder')}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent" className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
required required
/> />
@@ -820,12 +818,12 @@ export default function DashboardPage() {
{/* Project Description */} {/* Project Description */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Description {t('createProject.description')}
</label> </label>
<textarea <textarea
value={newProjectDesc} value={newProjectDesc}
onChange={(e) => setNewProjectDesc(e.target.value)} onChange={(e) => setNewProjectDesc(e.target.value)}
placeholder="Enter project description (optional)" placeholder={t('createProject.descriptionPlaceholder')}
rows={3} rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent resize-none" className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent resize-none"
/> />
@@ -845,14 +843,14 @@ export default function DashboardPage() {
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100" className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
disabled={createProjectLoading} disabled={createProjectLoading}
> >
Cancel {tCommon('cancel')}
</button> </button>
<button <button
type="submit" type="submit"
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={createProjectLoading} disabled={createProjectLoading}
> >
{createProjectLoading ? 'Creating...' : 'Create Project'} {createProjectLoading ? t('createProject.creating') : t('createProject.createButton')}
</button> </button>
</div> </div>
</form> </form>
@@ -862,4 +860,3 @@ export default function DashboardPage() {
</div> </div>
) )
} }

View File

@@ -1,7 +1,10 @@
import { useTranslation } from 'react-i18next'
import { useAuth } from '@/hooks' import { useAuth } from '@/hooks'
export default function HomePage() { export default function HomePage() {
const { isAuthenticated, login, isLoading } = useAuth() const { isAuthenticated, login, isLoading } = useAuth()
const { t: tHome } = useTranslation('home')
const { t: tNavbar } = useTranslation('navbar')
if (isLoading) { if (isLoading) {
return ( return (
@@ -32,10 +35,10 @@ export default function HomePage() {
</div> </div>
<h1 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl"> <h1 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">
Requirements Periodic Table {tHome('title')}
</h1> </h1>
<p className="mx-auto mt-4 max-w-md text-lg text-gray-600"> <p className="mx-auto mt-4 max-w-md text-lg text-gray-600">
Manage and track your project requirements. {tHome('subtitle')}
</p> </p>
<div className="mt-8"> <div className="mt-8">
@@ -57,14 +60,14 @@ export default function HomePage() {
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
/> />
</svg> </svg>
Login {tNavbar('login')}
</button> </button>
) : ( ) : (
<a <a
href="/dashboard" href="/dashboard"
className="inline-flex items-center gap-2 rounded-lg bg-primary-600 px-8 py-3 text-lg font-semibold text-white shadow-lg transition-all hover:bg-primary-700 hover:shadow-xl focus:outline-none focus:ring-4 focus:ring-primary-300" className="inline-flex items-center gap-2 rounded-lg bg-primary-600 px-8 py-3 text-lg font-semibold text-white shadow-lg transition-all hover:bg-primary-700 hover:shadow-xl focus:outline-none focus:ring-4 focus:ring-primary-300"
> >
Go to Dashboard {tHome('goToDashboard')}
</a> </a>
)} )}
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useAuth, useProject } from '@/hooks' import { useAuth, useProject } from '@/hooks'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { requirementService, validationService, relationshipService, commentService, tagService, priorityService, groupService, requirementStatusService } from '@/services' import { requirementService, validationService, relationshipService, commentService, tagService, priorityService, groupService, requirementStatusService } from '@/services'
@@ -40,6 +41,8 @@ interface TimelineEvent {
} }
export default function RequirementDetailPage() { export default function RequirementDetailPage() {
const { t } = useTranslation('requirementDetail')
const { t: tCommon } = useTranslation('common')
const { user, logout, isAuditor } = useAuth() const { user, logout, isAuditor } = useAuth()
const { currentProject } = useProject() const { currentProject } = useProject()
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
@@ -719,7 +722,7 @@ export default function RequirementDetailPage() {
<div className="min-h-screen bg-white flex items-center justify-center"> <div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-teal-600 mx-auto"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-teal-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading requirement...</p> <p className="mt-4 text-gray-600">{t('loadingRequirement')}</p>
</div> </div>
</div> </div>
) )
@@ -730,10 +733,10 @@ export default function RequirementDetailPage() {
<div className="min-h-screen bg-white flex items-center justify-center"> <div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<h2 className="text-2xl font-semibold text-gray-700 mb-4"> <h2 className="text-2xl font-semibold text-gray-700 mb-4">
{error || 'Requirement not found'} {error || t('requirementNotFound')}
</h2> </h2>
<Link to="/requirements" className="text-teal-600 hover:underline"> <Link to="/requirements" className="text-teal-600 hover:underline">
Back to Requirements {t('backToRequirements')}
</Link> </Link>
</div> </div>
</div> </div>
@@ -741,12 +744,12 @@ export default function RequirementDetailPage() {
} }
const tabs: { id: TabType; label: string }[] = [ const tabs: { id: TabType; label: string }[] = [
{ id: 'description', label: 'Description' }, { id: 'description', label: t('tabs.description') },
{ id: 'relationships', label: 'Relationships' }, { id: 'relationships', label: t('tabs.relationships') },
{ id: 'acceptance-criteria', label: 'Acceptance Criteria' }, { id: 'acceptance-criteria', label: t('tabs.acceptanceCriteria') },
{ id: 'shared-comments', label: 'Shared Comments' }, { id: 'shared-comments', label: t('tabs.sharedComments') },
{ id: 'validate', label: 'Validate' }, { id: 'validate', label: t('tabs.validate') },
{ id: 'history', label: 'History' }, { id: 'history', label: t('tabs.history') },
] ]
// Get display values from the requirement data // Get display values from the requirement data
@@ -759,7 +762,7 @@ export default function RequirementDetailPage() {
case 'description': case 'description':
return ( return (
<div> <div>
<h3 className="text-xl font-bold text-gray-800 mb-2">Description</h3> <h3 className="text-xl font-bold text-gray-800 mb-2">{t('description.title')}</h3>
{/* Draft indicator banner */} {/* Draft indicator banner */}
{isDraftStatus && ( {isDraftStatus && (
@@ -767,18 +770,18 @@ export default function RequirementDetailPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-amber-600 text-lg">📝</span> <span className="text-amber-600 text-lg">📝</span>
<div> <div>
<p className="font-semibold text-amber-800">Draft Requirement</p> <p className="font-semibold text-amber-800">{t('draft.title')}</p>
<p className="text-sm text-amber-700">This requirement is still in draft status and is not finalized. It may be subject to changes.</p> <p className="text-sm text-amber-700">{t('draft.message')}</p>
</div> </div>
</div> </div>
</div> </div>
)} )}
<p className="text-sm text-gray-700 mb-2"> <p className="text-sm text-gray-700 mb-2">
<span className="font-semibold">Version:</span> {requirement.version} <span className="font-semibold">{t('description.version')}</span> {requirement.version}
</p> </p>
<p className="text-sm text-gray-700 mb-2"> <p className="text-sm text-gray-700 mb-2">
<span className="font-semibold">Status:</span>{' '} <span className="font-semibold">{t('description.status')}</span>{' '}
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
isDraftStatus isDraftStatus
? 'bg-amber-100 text-amber-800 border border-amber-300' ? 'bg-amber-100 text-amber-800 border border-amber-300'
@@ -788,7 +791,7 @@ export default function RequirementDetailPage() {
</span> </span>
</p> </p>
<p className="text-sm text-gray-700 mb-2"> <p className="text-sm text-gray-700 mb-2">
<span className="font-semibold">Validation Status:</span>{' '} <span className="font-semibold">{t('description.validationStatus')}</span>{' '}
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getValidationStatusStyle(validationStatus)}`}> <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getValidationStatusStyle(validationStatus)}`}>
{validationStatus} {validationStatus}
</span> </span>
@@ -802,7 +805,7 @@ export default function RequirementDetailPage() {
)} )}
</p> </p>
<p className="text-sm text-gray-700 mb-2"> <p className="text-sm text-gray-700 mb-2">
<span className="font-semibold">Author:</span>{' '} <span className="font-semibold">{t('description.author')}</span>{' '}
{requirement.author_username ? ( {requirement.author_username ? (
<span className="text-gray-700">{requirement.author_username}</span> <span className="text-gray-700">{requirement.author_username}</span>
) : ( ) : (
@@ -811,22 +814,22 @@ export default function RequirementDetailPage() {
</p> </p>
{requirement.last_editor_username && ( {requirement.last_editor_username && (
<p className="text-sm text-gray-700 mb-2"> <p className="text-sm text-gray-700 mb-2">
<span className="font-semibold">Last Edited By:</span>{' '} <span className="font-semibold">{t('description.lastEditedBy')}</span>{' '}
<span className="text-gray-700">{requirement.last_editor_username}</span> <span className="text-gray-700">{requirement.last_editor_username}</span>
</p> </p>
)} )}
{requirement.created_at && ( {requirement.created_at && (
<p className="text-sm text-gray-700 mb-2"> <p className="text-sm text-gray-700 mb-2">
<span className="font-semibold">Created:</span> {new Date(requirement.created_at).toLocaleDateString()} <span className="font-semibold">{t('description.created')}</span> {new Date(requirement.created_at).toLocaleDateString()}
</p> </p>
)} )}
{requirement.updated_at && ( {requirement.updated_at && (
<p className="text-sm text-gray-700 mb-4"> <p className="text-sm text-gray-700 mb-4">
<span className="font-semibold">Last Updated:</span> {new Date(requirement.updated_at).toLocaleDateString()} <span className="font-semibold">{t('description.lastUpdated')}</span> {new Date(requirement.updated_at).toLocaleDateString()}
</p> </p>
)} )}
<div className="bg-teal-50 border border-gray-300 rounded min-h-[300px] p-4"> <div className="bg-teal-50 border border-gray-300 rounded min-h-[300px] p-4">
<p className="text-gray-700">{requirement.req_desc || 'No description provided.'}</p> <p className="text-gray-700">{requirement.req_desc || t('description.noDescription')}</p>
</div> </div>
{/* Hide Edit button for auditors */} {/* Hide Edit button for auditors */}
{!isAuditor && ( {!isAuditor && (
@@ -835,7 +838,7 @@ export default function RequirementDetailPage() {
onClick={openEditModal} onClick={openEditModal}
className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50" className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50"
> >
Edit {t('description.editButton')}
</button> </button>
</div> </div>
)} )}
@@ -846,43 +849,43 @@ export default function RequirementDetailPage() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold text-gray-800">Relationships</h3> <h3 className="text-xl font-bold text-gray-800">{t('relationships.title')}</h3>
{!isAuditor && ( {!isAuditor && (
<button <button
onClick={openAddRelationshipModal} onClick={openAddRelationshipModal}
disabled={relationshipTypes.length === 0} disabled={relationshipTypes.length === 0}
className="px-4 py-1.5 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-1.5 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
title={relationshipTypes.length === 0 ? 'No relationship types defined for this project. Contact an admin to set them up.' : ''} title={relationshipTypes.length === 0 ? t('relationships.noTypesWarning') : ''}
> >
Add Relationship {t('relationships.addButton')}
</button> </button>
)} )}
</div> </div>
{relationshipTypes.length === 0 && !relationshipsLoading && ( {relationshipTypes.length === 0 && !relationshipsLoading && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded text-sm text-yellow-800"> <div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded text-sm text-yellow-800">
No relationship types have been defined for this project. Contact an administrator to set up relationship types. {t('relationships.noTypesWarning')}
</div> </div>
)} )}
{relationshipsLoading ? ( {relationshipsLoading ? (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div>
<p className="mt-2 text-gray-500 text-sm">Loading relationships...</p> <p className="mt-2 text-gray-500 text-sm">{t('relationships.loadingRelationships')}</p>
</div> </div>
) : relationshipLinks.length === 0 ? ( ) : relationshipLinks.length === 0 ? (
<p className="text-gray-500 text-center py-8">No relationships defined yet.</p> <p className="text-gray-500 text-center py-8">{t('relationships.noRelationships')}</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full border border-gray-200 rounded"> <table className="w-full border border-gray-200 rounded">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Direction</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('relationships.tableHeaders.direction')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('relationships.tableHeaders.type')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Linked Requirement</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('relationships.tableHeaders.linkedRequirement')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created By</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('relationships.tableHeaders.createdBy')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('relationships.tableHeaders.date')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('relationships.tableHeaders.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
@@ -894,7 +897,7 @@ export default function RequirementDetailPage() {
? 'bg-blue-100 text-blue-800' ? 'bg-blue-100 text-blue-800'
: 'bg-purple-100 text-purple-800' : 'bg-purple-100 text-purple-800'
}`}> }`}>
{link.direction === 'outgoing' ? '→ Outgoing' : '← Incoming'} {link.direction === 'outgoing' ? t('relationships.outgoing') : t('relationships.incoming')}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-sm text-gray-700"> <td className="px-4 py-3 text-sm text-gray-700">
@@ -944,12 +947,12 @@ export default function RequirementDetailPage() {
case 'acceptance-criteria': case 'acceptance-criteria':
return ( return (
<div> <div>
<h3 className="text-xl font-bold text-gray-800 mb-4">Acceptance Criteria</h3> <h3 className="text-xl font-bold text-gray-800 mb-4">{t('acceptanceCriteria.title')}</h3>
<p className="text-gray-500">No acceptance criteria defined yet.</p> <p className="text-gray-500">{t('acceptanceCriteria.noCriteria')}</p>
{!isAuditor && ( {!isAuditor && (
<div className="mt-4"> <div className="mt-4">
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50"> <button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
Add Criterion {t('acceptanceCriteria.addButton')}
</button> </button>
</div> </div>
)} )}
@@ -959,14 +962,14 @@ export default function RequirementDetailPage() {
case 'shared-comments': case 'shared-comments':
return ( return (
<div> <div>
<h3 className="text-xl font-bold text-gray-800 mb-4">Shared Comments</h3> <h3 className="text-xl font-bold text-gray-800 mb-4">{t('comments.title')}</h3>
{/* New Comment Form */} {/* New Comment Form */}
<div className="mb-6 p-4 border border-gray-200 rounded bg-gray-50"> <div className="mb-6 p-4 border border-gray-200 rounded bg-gray-50">
<textarea <textarea
value={newCommentText} value={newCommentText}
onChange={(e) => setNewCommentText(e.target.value)} onChange={(e) => setNewCommentText(e.target.value)}
placeholder="Add a comment..." placeholder={t('comments.placeholder')}
className="w-full p-3 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none" className="w-full p-3 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
rows={3} rows={3}
disabled={postingComment} disabled={postingComment}
@@ -977,7 +980,7 @@ export default function RequirementDetailPage() {
disabled={postingComment || !newCommentText.trim()} disabled={postingComment || !newCommentText.trim()}
className="px-4 py-1.5 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-1.5 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{postingComment ? 'Posting...' : 'Post Comment'} {postingComment ? t('comments.posting') : t('comments.postButton')}
</button> </button>
</div> </div>
</div> </div>
@@ -986,10 +989,10 @@ export default function RequirementDetailPage() {
{commentsLoading ? ( {commentsLoading ? (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div>
<p className="mt-2 text-gray-500 text-sm">Loading comments...</p> <p className="mt-2 text-gray-500 text-sm">{t('comments.loadingComments')}</p>
</div> </div>
) : comments.length === 0 ? ( ) : comments.length === 0 ? (
<p className="text-gray-500 text-center py-8">No comments yet. Be the first to comment!</p> <p className="text-gray-500 text-center py-8">{t('comments.noComments')}</p>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{comments.map((comment) => ( {comments.map((comment) => (
@@ -1038,7 +1041,7 @@ export default function RequirementDetailPage() {
}} }}
className="mt-2 text-xs text-teal-600 hover:text-teal-700 font-medium" className="mt-2 text-xs text-teal-600 hover:text-teal-700 font-medium"
> >
{replyingToCommentId === comment.id ? 'Cancel Reply' : 'Reply'} {replyingToCommentId === comment.id ? t('comments.cancelReply') : t('comments.reply')}
</button> </button>
{/* Reply Form */} {/* Reply Form */}
@@ -1047,7 +1050,7 @@ export default function RequirementDetailPage() {
<textarea <textarea
value={replyText} value={replyText}
onChange={(e) => setReplyText(e.target.value)} onChange={(e) => setReplyText(e.target.value)}
placeholder="Write a reply..." placeholder={t('comments.replyPlaceholder')}
className="w-full p-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none" className="w-full p-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
rows={2} rows={2}
disabled={postingReply} disabled={postingReply}
@@ -1058,7 +1061,7 @@ export default function RequirementDetailPage() {
disabled={postingReply || !replyText.trim()} disabled={postingReply || !replyText.trim()}
className="px-3 py-1 bg-teal-600 text-white rounded text-xs font-medium hover:bg-teal-700 disabled:opacity-50" className="px-3 py-1 bg-teal-600 text-white rounded text-xs font-medium hover:bg-teal-700 disabled:opacity-50"
> >
{postingReply ? 'Posting...' : 'Post Reply'} {postingReply ? t('comments.posting') : t('comments.postReply')}
</button> </button>
</div> </div>
</div> </div>
@@ -1117,25 +1120,25 @@ export default function RequirementDetailPage() {
case 'validate': case 'validate':
return ( return (
<div> <div>
<h3 className="text-xl font-bold text-gray-800 mb-4">Validate Requirement</h3> <h3 className="text-xl font-bold text-gray-800 mb-4">{t('validate.title')}</h3>
{/* Current Status */} {/* Current Status */}
<div className="mb-6 p-4 bg-gray-50 border border-gray-200 rounded"> <div className="mb-6 p-4 bg-gray-50 border border-gray-200 rounded">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-gray-600">Current Status:</p> <p className="text-sm text-gray-600">{t('validate.currentStatus')}</p>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${getValidationStatusStyle(validationStatus)}`}> <span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${getValidationStatusStyle(validationStatus)}`}>
{validationStatus} {validationStatus}
</span> </span>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-sm text-gray-600">Requirement Version:</p> <p className="text-sm text-gray-600">{t('validate.requirementVersion')}</p>
<span className="text-lg font-semibold text-gray-800">{requirement.version}</span> <span className="text-lg font-semibold text-gray-800">{requirement.version}</span>
</div> </div>
</div> </div>
{requirement.validated_by && ( {requirement.validated_by && (
<p className="text-sm text-gray-500 mt-2"> <p className="text-sm text-gray-500 mt-2">
Last validated by {requirement.validated_by} {t('validate.lastValidatedBy')} {requirement.validated_by}
{requirement.validated_at && ` on ${new Date(requirement.validated_at).toLocaleDateString()}`} {requirement.validated_at && ` on ${new Date(requirement.validated_at).toLocaleDateString()}`}
</p> </p>
)} )}
@@ -1144,7 +1147,7 @@ export default function RequirementDetailPage() {
<p className="text-sm text-orange-800 flex items-center gap-2"> <p className="text-sm text-orange-800 flex items-center gap-2">
<span></span> <span></span>
<span> <span>
This requirement was modified after the last validation (validated at version {requirement.validation_version}, current version {requirement.version}). {t('validate.staleWarning', { validationVersion: requirement.validation_version, currentVersion: requirement.version })}
</span> </span>
</p> </p>
</div> </div>
@@ -1154,7 +1157,7 @@ export default function RequirementDetailPage() {
{/* Validation Form - Only for auditors */} {/* Validation Form - Only for auditors */}
{isAuditor && ( {isAuditor && (
<div className="mb-6 p-4 border border-gray-300 rounded"> <div className="mb-6 p-4 border border-gray-300 rounded">
<h4 className="font-semibold text-gray-800 mb-4">Submit Validation</h4> <h4 className="font-semibold text-gray-800 mb-4">{t('validate.submitTitle')}</h4>
{validationError && ( {validationError && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm"> <div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
@@ -1166,7 +1169,7 @@ export default function RequirementDetailPage() {
{/* Status Selection */} {/* Status Selection */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Validation Status <span className="text-red-500">*</span> {t('validate.statusLabel')} <span className="text-red-500">*</span>
</label> </label>
<select <select
value={selectedStatusId} value={selectedStatusId}
@@ -1174,7 +1177,7 @@ export default function RequirementDetailPage() {
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent" className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
disabled={validationLoading} disabled={validationLoading}
> >
<option value="">Select a status...</option> <option value="">{t('validate.selectStatus')}</option>
{validationStatuses.map((status) => ( {validationStatuses.map((status) => (
<option key={status.id} value={status.id}> <option key={status.id} value={status.id}>
{status.status_name} {status.status_name}
@@ -1186,12 +1189,12 @@ export default function RequirementDetailPage() {
{/* Comment */} {/* Comment */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Comment {t('validate.commentLabel')}
</label> </label>
<textarea <textarea
value={validationComment} value={validationComment}
onChange={(e) => setValidationComment(e.target.value)} onChange={(e) => setValidationComment(e.target.value)}
placeholder="Add a comment explaining your decision (optional but recommended)" placeholder={t('validate.commentPlaceholder')}
rows={4} rows={4}
className="w-full p-3 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none" className="w-full p-3 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
disabled={validationLoading} disabled={validationLoading}
@@ -1204,7 +1207,7 @@ export default function RequirementDetailPage() {
disabled={validationLoading || !selectedStatusId} disabled={validationLoading || !selectedStatusId}
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{validationLoading ? 'Submitting...' : 'Submit Validation'} {validationLoading ? t('validate.submitting') : t('validate.submitButton')}
</button> </button>
</div> </div>
</div> </div>
@@ -1212,25 +1215,25 @@ export default function RequirementDetailPage() {
{/* Validation History */} {/* Validation History */}
<div> <div>
<h4 className="font-semibold text-gray-800 mb-4">Validation History</h4> <h4 className="font-semibold text-gray-800 mb-4">{t('validate.historyTitle')}</h4>
{historyLoading ? ( {historyLoading ? (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div>
<p className="mt-2 text-gray-500 text-sm">Loading history...</p> <p className="mt-2 text-gray-500 text-sm">{t('validate.loadingHistory')}</p>
</div> </div>
) : validationHistory.length === 0 ? ( ) : validationHistory.length === 0 ? (
<p className="text-gray-500 text-center py-8">No validation history yet.</p> <p className="text-gray-500 text-center py-8">{t('validate.noHistory')}</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full border border-gray-200 rounded"> <table className="w-full border border-gray-200 rounded">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('validate.tableHeaders.date')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('validate.tableHeaders.status')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('validate.tableHeaders.version')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Validator</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('validate.tableHeaders.validator')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Comment</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('validate.tableHeaders.comment')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
@@ -1252,7 +1255,7 @@ export default function RequirementDetailPage() {
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
v{validation.req_version_snapshot} v{validation.req_version_snapshot}
{isStale && ( {isStale && (
<span className="text-orange-600" title="Requirement was modified after this validation"> <span className="text-orange-600" title={t('validate.staleWarning', { validationVersion: validation.req_version_snapshot, currentVersion: requirement.version })}>
</span> </span>
)} )}
@@ -1262,7 +1265,7 @@ export default function RequirementDetailPage() {
{validation.validator_username} {validation.validator_username}
</td> </td>
<td className="px-4 py-3 text-sm text-gray-700"> <td className="px-4 py-3 text-sm text-gray-700">
{validation.comment || <span className="text-gray-400 italic">No comment</span>} {validation.comment || <span className="text-gray-400 italic">{t('validate.noComment')}</span>}
</td> </td>
</tr> </tr>
) )
@@ -1290,11 +1293,11 @@ export default function RequirementDetailPage() {
return ( return (
<div> <div>
<h3 className="text-xl font-bold text-gray-800 mb-4">Version History</h3> <h3 className="text-xl font-bold text-gray-800 mb-4">{t('history.title')}</h3>
{/* Author info */} {/* Author info */}
<div className="mb-4 p-3 bg-gray-50 border border-gray-200 rounded text-sm"> <div className="mb-4 p-3 bg-gray-50 border border-gray-200 rounded text-sm">
<span className="font-medium text-gray-700">Original Author:</span>{' '} <span className="font-medium text-gray-700">{t('history.originalAuthor')}</span>{' '}
{requirement.author_username ? ( {requirement.author_username ? (
<span className="text-gray-800">{requirement.author_username}</span> <span className="text-gray-800">{requirement.author_username}</span>
) : ( ) : (
@@ -1310,10 +1313,10 @@ export default function RequirementDetailPage() {
{reqHistoryLoading ? ( {reqHistoryLoading ? (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div>
<p className="mt-2 text-gray-500 text-sm">Loading history...</p> <p className="mt-2 text-gray-500 text-sm">{t('history.loadingHistory')}</p>
</div> </div>
) : timelineEvents.length === 0 ? ( ) : timelineEvents.length === 0 ? (
<p className="text-gray-500 text-center py-8">No history yet. History is recorded when the requirement is edited or relationships change.</p> <p className="text-gray-500 text-center py-8">{t('history.noHistory')}</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{timelineEvents.map((event) => { {timelineEvents.map((event) => {
@@ -1346,7 +1349,7 @@ export default function RequirementDetailPage() {
<span className="font-semibold text-gray-800"> <span className="font-semibold text-gray-800">
v{oldItem.version} v{newItem ? newItem.version : requirement.version} v{oldItem.version} v{newItem ? newItem.version : requirement.version}
</span> </span>
<span className="text-sm text-gray-600">Requirement edited</span> <span className="text-sm text-gray-600">{t('history.requirementEdited')}</span>
</div> </div>
<div className="flex items-center gap-4 text-sm text-gray-500"> <div className="flex items-center gap-4 text-sm text-gray-500">
{oldItem.edited_by_username && ( {oldItem.edited_by_username && (
@@ -1365,7 +1368,7 @@ export default function RequirementDetailPage() {
{isExpanded && ( {isExpanded && (
<div className="px-4 py-3 border-t border-gray-200 bg-white space-y-4"> <div className="px-4 py-3 border-t border-gray-200 bg-white space-y-4">
{!hasAnyChange && ( {!hasAnyChange && (
<p className="text-gray-500 italic text-sm">No visible changes detected (may be a group-only change).</p> <p className="text-gray-500 italic text-sm">{t('history.noChangesDetected')}</p>
)} )}
{/* Name diff */} {/* Name diff */}
@@ -1463,7 +1466,7 @@ export default function RequirementDetailPage() {
}} }}
className="mt-2 text-sm text-teal-600 hover:underline" className="mt-2 text-sm text-teal-600 hover:underline"
> >
{showFullDescDiff === event.id ? 'Show less' : 'Show full diff'} {showFullDescDiff === event.id ? t('history.showLess') : t('history.showFullDiff')}
</button> </button>
)} )}
</div> </div>
@@ -1481,7 +1484,7 @@ export default function RequirementDetailPage() {
<div className="flex items-center justify-between px-4 py-3 bg-green-50 border-l-4 border-green-400"> <div className="flex items-center justify-between px-4 py-3 bg-green-50 border-l-4 border-green-400">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-green-600 text-lg">🔗</span> <span className="text-green-600 text-lg">🔗</span>
<span className="font-medium text-gray-800">Link Created</span> <span className="font-medium text-gray-800">{t('history.linkCreated')}</span>
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600">
{link.isSource ? ( {link.isSource ? (
<> <>
@@ -1528,7 +1531,7 @@ export default function RequirementDetailPage() {
<div className="flex items-center justify-between px-4 py-3 bg-red-50 border-l-4 border-red-400"> <div className="flex items-center justify-between px-4 py-3 bg-red-50 border-l-4 border-red-400">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-red-600 text-lg">🔗</span> <span className="text-red-600 text-lg">🔗</span>
<span className="font-medium text-gray-800">Link Removed</span> <span className="font-medium text-gray-800">{t('history.linkRemoved')}</span>
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600">
{link.isSource ? ( {link.isSource ? (
<> <>
@@ -1572,7 +1575,7 @@ export default function RequirementDetailPage() {
<div className="flex items-center justify-between px-4 py-3 bg-green-50 border-l-4 border-green-400"> <div className="flex items-center justify-between px-4 py-3 bg-green-50 border-l-4 border-green-400">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-green-600 text-lg">🏷</span> <span className="text-green-600 text-lg">🏷</span>
<span className="font-medium text-gray-800">Group Added</span> <span className="font-medium text-gray-800">{t('history.groupAdded')}</span>
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600">
{group.groupName ? ( {group.groupName ? (
<span <span
@@ -1606,7 +1609,7 @@ export default function RequirementDetailPage() {
<div className="flex items-center justify-between px-4 py-3 bg-orange-50 border-l-4 border-orange-400"> <div className="flex items-center justify-between px-4 py-3 bg-orange-50 border-l-4 border-orange-400">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-orange-600 text-lg">🏷</span> <span className="text-orange-600 text-lg">🏷</span>
<span className="font-medium text-gray-800">Group Removed</span> <span className="font-medium text-gray-800">{t('history.groupRemoved')}</span>
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600">
{group.groupName ? ( {group.groupName ? (
<span <span
@@ -1650,7 +1653,7 @@ export default function RequirementDetailPage() {
{/* Header */} {/* Header */}
<header className="py-6 text-center"> <header className="py-6 text-center">
<h1 className="text-3xl font-semibold text-teal-700"> <h1 className="text-3xl font-semibold text-teal-700">
Digital Twin Requirements Tool {t('pageTitle')}
</h1> </h1>
</header> </header>
@@ -1659,22 +1662,13 @@ export default function RequirementDetailPage() {
<div className="flex items-center justify-between max-w-7xl mx-auto"> <div className="flex items-center justify-between max-w-7xl mx-auto">
{/* Breadcrumb */} {/* Breadcrumb */}
<div className="text-sm"> <div className="text-sm">
<Link to="/dashboard" className="text-gray-600 hover:underline">Projects</Link> <Link to="/dashboard" className="text-gray-600 hover:underline">{tCommon('projects')}</Link>
<span className="mx-2 text-gray-400">»</span> <span className="mx-2 text-gray-400">»</span>
<Link to="/dashboard" className="text-gray-600 hover:underline">{currentProject?.project_name || 'Project'}</Link> <Link to="/dashboard" className="text-gray-600 hover:underline">{currentProject?.project_name || tCommon('project')}</Link>
<span className="mx-2 text-gray-400">»</span> <span className="mx-2 text-gray-400">»</span>
<Link to="/requirements" className="text-gray-600 hover:underline">Search</Link> <Link to="/requirements" className="text-gray-600 hover:underline">{t('breadcrumb.search')}</Link>
<span className="mx-2 text-gray-400">»</span> <span className="mx-2 text-gray-400">»</span>
<span className="font-semibold text-gray-900">Details {tagCode}</span> <span className="font-semibold text-gray-900">{t('breadcrumb.details')} {tagCode}</span>
</div>
{/* Language Toggle */}
<div className="flex items-center gap-2 text-sm text-gray-600">
<span>English</span>
<div className="relative inline-flex h-5 w-10 items-center rounded-full bg-gray-300">
<span className="inline-block h-4 w-4 transform rounded-full bg-white shadow-sm translate-x-0.5" />
</div>
<span>Portuguese</span>
</div> </div>
{/* User Info */} {/* User Info */}
@@ -1684,15 +1678,15 @@ export default function RequirementDetailPage() {
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" /> <path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
</svg> </svg>
<span className="text-sm text-gray-700"> <span className="text-sm text-gray-700">
{user?.full_name || user?.preferred_username || 'User'}{' '} {user?.full_name || user?.preferred_username || tCommon('user')}{' '}
<span className="text-gray-500">({user?.role || 'user'})</span> <span className="text-gray-500">({user?.role || tCommon('user')})</span>
</span> </span>
</div> </div>
<button <button
onClick={logout} onClick={logout}
className="px-3 py-1 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50" className="px-3 py-1 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50"
> >
Logout {tCommon('logout')}
</button> </button>
</div> </div>
</div> </div>
@@ -1706,7 +1700,7 @@ export default function RequirementDetailPage() {
{isDraftStatus && ( {isDraftStatus && (
<div className="mb-4 inline-flex items-center gap-2 px-4 py-2 bg-amber-100 border-2 border-dashed border-amber-400 rounded-lg"> <div className="mb-4 inline-flex items-center gap-2 px-4 py-2 bg-amber-100 border-2 border-dashed border-amber-400 rounded-lg">
<span className="text-amber-600 text-lg">📝</span> <span className="text-amber-600 text-lg">📝</span>
<span className="text-amber-800 font-medium">Draft - Not Finalized</span> <span className="text-amber-800 font-medium">{t('draft.badge')}</span>
</div> </div>
)} )}
@@ -1788,7 +1782,7 @@ export default function RequirementDetailPage() {
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4"> <div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
{/* Modal Header */} {/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800">Add Relationship</h2> <h2 className="text-xl font-semibold text-gray-800">{t('addRelationshipModal.title')}</h2>
<button <button
onClick={closeAddRelationshipModal} onClick={closeAddRelationshipModal}
className="text-gray-400 hover:text-gray-600" className="text-gray-400 hover:text-gray-600"
@@ -1808,7 +1802,7 @@ export default function RequirementDetailPage() {
{/* Relationship Type Selection */} {/* Relationship Type Selection */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Relationship Type <span className="text-red-500">*</span> {t('addRelationshipModal.relationshipType')} <span className="text-red-500">*</span>
</label> </label>
<select <select
value={selectedRelationshipType} value={selectedRelationshipType}
@@ -1816,7 +1810,7 @@ export default function RequirementDetailPage() {
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent" className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
disabled={addLinkLoading} disabled={addLinkLoading}
> >
<option value="">Select a relationship type...</option> <option value="">{t('addRelationshipModal.selectType')}</option>
{relationshipTypes.map((type) => ( {relationshipTypes.map((type) => (
<option key={type.id} value={type.id}> <option key={type.id} value={type.id}>
{type.type_name} {type.type_name}
@@ -1829,7 +1823,7 @@ export default function RequirementDetailPage() {
{/* Target Requirement Search */} {/* Target Requirement Search */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Target Requirement <span className="text-red-500">*</span> {t('addRelationshipModal.targetRequirement')} <span className="text-red-500">*</span>
</label> </label>
<div className="relative"> <div className="relative">
<input <input
@@ -1839,7 +1833,7 @@ export default function RequirementDetailPage() {
setTargetSearchQuery(e.target.value) setTargetSearchQuery(e.target.value)
setSelectedTarget(null) setSelectedTarget(null)
}} }}
placeholder="Search by tag code or name..." placeholder={t('addRelationshipModal.searchPlaceholder')}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent" className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
disabled={addLinkLoading} disabled={addLinkLoading}
/> />
@@ -1868,7 +1862,7 @@ export default function RequirementDetailPage() {
</div> </div>
{selectedTarget && ( {selectedTarget && (
<p className="mt-1 text-sm text-green-600"> <p className="mt-1 text-sm text-green-600">
Selected: {selectedTarget.tag_code} - {selectedTarget.req_name} {t('addRelationshipModal.selected')} {selectedTarget.tag_code} - {selectedTarget.req_name}
</p> </p>
)} )}
</div> </div>
@@ -1882,7 +1876,7 @@ export default function RequirementDetailPage() {
className="px-4 py-2 border border-gray-300 rounded text-sm text-gray-700 hover:bg-gray-100" className="px-4 py-2 border border-gray-300 rounded text-sm text-gray-700 hover:bg-gray-100"
disabled={addLinkLoading} disabled={addLinkLoading}
> >
Cancel {tCommon('cancel')}
</button> </button>
<button <button
type="button" type="button"
@@ -1890,7 +1884,7 @@ export default function RequirementDetailPage() {
disabled={addLinkLoading || !selectedRelationshipType || !selectedTarget} disabled={addLinkLoading || !selectedRelationshipType || !selectedTarget}
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{addLinkLoading ? 'Creating...' : 'Create Relationship'} {addLinkLoading ? tCommon('creating') : t('addRelationshipModal.createButton')}
</button> </button>
</div> </div>
</div> </div>
@@ -1903,7 +1897,7 @@ export default function RequirementDetailPage() {
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-hidden flex flex-col"> <div className="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Modal Header */} {/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800">Edit Requirement</h2> <h2 className="text-xl font-semibold text-gray-800">{t('editModal.title')}</h2>
<button <button
onClick={closeEditModal} onClick={closeEditModal}
className="text-gray-400 hover:text-gray-600" className="text-gray-400 hover:text-gray-600"
@@ -1923,14 +1917,14 @@ export default function RequirementDetailPage() {
{editOptionsLoading ? ( {editOptionsLoading ? (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div>
<p className="mt-2 text-gray-500 text-sm">Loading options...</p> <p className="mt-2 text-gray-500 text-sm">{t('editModal.loadingOptions')}</p>
</div> </div>
) : ( ) : (
<> <>
{/* Name */} {/* Name */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Name <span className="text-red-500">*</span> {t('editModal.name')} <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
@@ -1944,7 +1938,7 @@ export default function RequirementDetailPage() {
{/* Tag */} {/* Tag */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Tag <span className="text-red-500">*</span> {t('editModal.tag')} <span className="text-red-500">*</span>
</label> </label>
<select <select
value={editTagId} value={editTagId}
@@ -1952,7 +1946,7 @@ export default function RequirementDetailPage() {
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent" className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
disabled={editLoading} disabled={editLoading}
> >
<option value="">Select a tag...</option> <option value="">{t('editModal.selectTag')}</option>
{availableTags.map((tag) => ( {availableTags.map((tag) => (
<option key={tag.id} value={tag.id}> <option key={tag.id} value={tag.id}>
{tag.tag_code} - {tag.tag_description} {tag.tag_code} - {tag.tag_description}
@@ -1964,7 +1958,7 @@ export default function RequirementDetailPage() {
{/* Priority */} {/* Priority */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Priority {t('editModal.priority')}
</label> </label>
<select <select
value={editPriorityId} value={editPriorityId}
@@ -1972,7 +1966,7 @@ export default function RequirementDetailPage() {
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent" className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
disabled={editLoading} disabled={editLoading}
> >
<option value="">No priority</option> <option value="">{t('editModal.noPriority')}</option>
{availablePriorities.map((priority) => ( {availablePriorities.map((priority) => (
<option key={priority.id} value={priority.id}> <option key={priority.id} value={priority.id}>
{priority.priority_name} {priority.priority_name}
@@ -1984,7 +1978,7 @@ export default function RequirementDetailPage() {
{/* Status */} {/* Status */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Status {t('editModal.status')}
</label> </label>
<select <select
value={editStatusId} value={editStatusId}
@@ -1999,14 +1993,14 @@ export default function RequirementDetailPage() {
))} ))}
</select> </select>
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">
Draft requirements are not finalized and marked with a visual indicator. {t('editModal.draftNote')}
</p> </p>
</div> </div>
{/* Description */} {/* Description */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Description {t('editModal.description')}
</label> </label>
<textarea <textarea
value={editReqDesc} value={editReqDesc}
@@ -2020,10 +2014,10 @@ export default function RequirementDetailPage() {
{/* Groups */} {/* Groups */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Groups {t('editModal.groups')}
</label> </label>
{availableGroups.length === 0 ? ( {availableGroups.length === 0 ? (
<p className="text-sm text-gray-500 italic">No groups available</p> <p className="text-sm text-gray-500 italic">{t('editModal.noGroupsAvailable')}</p>
) : ( ) : (
<div className="grid grid-cols-2 gap-2 max-h-40 overflow-y-auto border border-gray-200 rounded p-3"> <div className="grid grid-cols-2 gap-2 max-h-40 overflow-y-auto border border-gray-200 rounded p-3">
{availableGroups.map((group) => ( {availableGroups.map((group) => (
@@ -2064,7 +2058,7 @@ export default function RequirementDetailPage() {
className="px-4 py-2 border border-gray-300 rounded text-sm text-gray-700 hover:bg-gray-100" className="px-4 py-2 border border-gray-300 rounded text-sm text-gray-700 hover:bg-gray-100"
disabled={editLoading} disabled={editLoading}
> >
Cancel {tCommon('cancel')}
</button> </button>
<button <button
type="button" type="button"
@@ -2072,7 +2066,7 @@ export default function RequirementDetailPage() {
disabled={editLoading || editOptionsLoading || !editReqName.trim() || !editTagId} disabled={editLoading || editOptionsLoading || !editReqName.trim() || !editTagId}
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{editLoading ? 'Saving...' : 'Save Changes'} {editLoading ? tCommon('saving') : tCommon('saveChanges')}
</button> </button>
</div> </div>
</div> </div>
@@ -2114,14 +2108,14 @@ export default function RequirementDetailPage() {
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100" className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
disabled={deleteConfirmLoading} disabled={deleteConfirmLoading}
> >
Cancel {tCommon('cancel')}
</button> </button>
<button <button
onClick={handleConfirmDelete} onClick={handleConfirmDelete}
className="px-4 py-2 bg-red-600 text-white rounded text-sm font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 bg-red-600 text-white rounded text-sm font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={deleteConfirmLoading} disabled={deleteConfirmLoading}
> >
{deleteConfirmLoading ? 'Deleting...' : 'Delete'} {deleteConfirmLoading ? tCommon('deleting') : tCommon('delete')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,9 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useAuth, useProject } from '@/hooks' import { useAuth, useProject } from '@/hooks'
import { useSearchParams, Link, useNavigate, useLocation } from 'react-router-dom' import { useSearchParams, Link, useNavigate, useLocation } from 'react-router-dom'
import { groupService, tagService, requirementService, priorityService } from '@/services' import { groupService, tagService, requirementService, priorityService } from '@/services'
import { LanguageSelector } from '@/components'
import type { Group } from '@/services/groupService' import type { Group } from '@/services/groupService'
import type { Tag } from '@/services/tagService' import type { Tag } from '@/services/tagService'
import type { Priority } from '@/services/priorityService' import type { Priority } from '@/services/priorityService'
@@ -30,6 +32,8 @@ const isDraftStatus = (statusCode: string | undefined): boolean => {
} }
export default function RequirementsPage() { export default function RequirementsPage() {
const { t } = useTranslation('requirements')
const { t: tCommon } = useTranslation('common')
const { user, logout, isAuditor } = useAuth() const { user, logout, isAuditor } = useAuth()
const { currentProject, isLoading: projectLoading } = useProject() const { currentProject, isLoading: projectLoading } = useProject()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
@@ -366,7 +370,7 @@ export default function RequirementsPage() {
<div className="min-h-screen bg-white flex items-center justify-center"> <div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-teal-600 mx-auto"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-teal-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading requirements...</p> <p className="mt-4 text-gray-600">{t('loadingRequirements')}</p>
</div> </div>
</div> </div>
) )
@@ -379,13 +383,13 @@ export default function RequirementsPage() {
<svg className="w-16 h-16 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-16 h-16 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg> </svg>
<h2 className="text-xl font-semibold text-gray-700 mb-2">No Project Selected</h2> <h2 className="text-xl font-semibold text-gray-700 mb-2">{t('noProjectSelected.title')}</h2>
<p className="text-gray-500 mb-4">Please select a project from the dashboard to view requirements.</p> <p className="text-gray-500 mb-4">{t('noProjectSelected.message')}</p>
<button <button
onClick={() => navigate('/dashboard')} onClick={() => navigate('/dashboard')}
className="px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700" className="px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700"
> >
Go to Dashboard {t('noProjectSelected.goToDashboard')}
</button> </button>
</div> </div>
</div> </div>
@@ -401,7 +405,7 @@ export default function RequirementsPage() {
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700" className="px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700"
> >
Retry {tCommon('retry')}
</button> </button>
</div> </div>
</div> </div>
@@ -413,7 +417,7 @@ export default function RequirementsPage() {
{/* Header */} {/* Header */}
<header className="py-6 text-center"> <header className="py-6 text-center">
<h1 className="text-3xl font-semibold text-teal-700"> <h1 className="text-3xl font-semibold text-teal-700">
Digital Twin Requirements Tool {t('header.title')}
</h1> </h1>
</header> </header>
@@ -422,21 +426,15 @@ export default function RequirementsPage() {
<div className="flex items-center justify-between max-w-7xl mx-auto"> <div className="flex items-center justify-between max-w-7xl mx-auto">
{/* Breadcrumb */} {/* Breadcrumb */}
<div className="text-sm"> <div className="text-sm">
<Link to="/dashboard" className="text-gray-600 hover:underline">Projects</Link> <Link to="/dashboard" className="text-gray-600 hover:underline">{t('breadcrumb.projects')}</Link>
<span className="mx-2 text-gray-400">»</span> <span className="mx-2 text-gray-400">»</span>
<Link to="/dashboard" className="text-gray-600 hover:underline">{currentProject.project_name}</Link> <Link to="/dashboard" className="text-gray-600 hover:underline">{currentProject.project_name}</Link>
<span className="mx-2 text-gray-400">»</span> <span className="mx-2 text-gray-400">»</span>
<span className="font-semibold text-gray-900">Search Requirements</span> <span className="font-semibold text-gray-900">{t('breadcrumb.searchRequirements')}</span>
</div> </div>
{/* Language Toggle */} {/* Language Selector */}
<div className="flex items-center gap-2 text-sm text-gray-600"> <LanguageSelector compact />
<span>English</span>
<div className="relative inline-flex h-5 w-10 items-center rounded-full bg-gray-300">
<span className="inline-block h-4 w-4 transform rounded-full bg-white shadow-sm translate-x-0.5" />
</div>
<span>Portuguese</span>
</div>
{/* User Info */} {/* User Info */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -445,15 +443,15 @@ export default function RequirementsPage() {
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" /> <path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
</svg> </svg>
<span className="text-sm text-gray-700"> <span className="text-sm text-gray-700">
{user?.full_name || user?.preferred_username || 'User'}{' '} {user?.full_name || user?.preferred_username || tCommon('user')}{' '}
<span className="text-gray-500">({user?.role || 'user'})</span> <span className="text-gray-500">({user?.role || tCommon('user').toLowerCase()})</span>
</span> </span>
</div> </div>
<button <button
onClick={logout} onClick={logout}
className="px-3 py-1 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50" className="px-3 py-1 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50"
> >
Logout {tCommon('logout')}
</button> </button>
</div> </div>
</div> </div>
@@ -470,7 +468,7 @@ export default function RequirementsPage() {
onClick={openCreateModal} onClick={openCreateModal}
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50" className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50"
> >
New Requirement {t('newRequirement')}
</button> </button>
<button <button
onClick={toggleDeletedPanel} onClick={toggleDeletedPanel}
@@ -483,7 +481,7 @@ export default function RequirementsPage() {
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg> </svg>
Deleted {t('deleted')}
{deletedRequirements.length > 0 && ( {deletedRequirements.length > 0 && (
<span className="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded-full"> <span className="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded-full">
{deletedRequirements.length} {deletedRequirements.length}
@@ -505,7 +503,7 @@ export default function RequirementsPage() {
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg> </svg>
Deleted {t('deleted')}
{deletedRequirements.length > 0 && ( {deletedRequirements.length > 0 && (
<span className="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded-full"> <span className="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded-full">
{deletedRequirements.length} {deletedRequirements.length}
@@ -519,7 +517,7 @@ export default function RequirementsPage() {
<div className="flex gap-2 mb-6"> <div className="flex gap-2 mb-6">
<input <input
type="text" type="text"
placeholder="Search for a requirement tag or title" placeholder={t('searchPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1 px-4 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent" className="flex-1 px-4 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
@@ -528,19 +526,19 @@ export default function RequirementsPage() {
onClick={handleSearch} onClick={handleSearch}
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50" className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50"
> >
Search {tCommon('search')}
</button> </button>
<button <button
onClick={handleClear} onClick={handleClear}
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50" className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50"
> >
Clear {tCommon('clear')}
</button> </button>
</div> </div>
{/* Filter Group */} {/* Filter Group */}
<div className="mb-6"> <div className="mb-6">
<p className="text-sm text-gray-600 mb-3">Filter Group</p> <p className="text-sm text-gray-600 mb-3">{t('filters.filterGroup')}</p>
<div className="grid grid-cols-3 gap-x-8 gap-y-2"> <div className="grid grid-cols-3 gap-x-8 gap-y-2">
{groups.map((group) => ( {groups.map((group) => (
<label key={group.id} className="flex items-center gap-2 cursor-pointer"> <label key={group.id} className="flex items-center gap-2 cursor-pointer">
@@ -559,16 +557,16 @@ export default function RequirementsPage() {
{/* Filter Validation Status */} {/* Filter Validation Status */}
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<p className="text-sm text-gray-600">Filter Validation Status</p> <p className="text-sm text-gray-600">{t('filters.filterValidationStatus')}</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{needsRevalidationFilter && ( {needsRevalidationFilter && (
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full"> <span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">
Showing: Needs Revalidation {t('filters.showingNeedsRevalidation')}
</span> </span>
)} )}
{selectedValidationStatuses.includes('__NEEDS_ATTENTION__') && ( {selectedValidationStatuses.includes('__NEEDS_ATTENTION__') && (
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-full"> <span className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-full">
Showing: Needs Attention {t('filters.showingNeedsAttention')}
</span> </span>
)} )}
</div> </div>
@@ -587,7 +585,7 @@ export default function RequirementsPage() {
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg> </svg>
Needs Revalidation {t('filters.needsRevalidation')}
</button> </button>
)} )}
{['Approved', 'Denied', 'Partially Approved', 'Not Validated'].map((status) => { {['Approved', 'Denied', 'Partially Approved', 'Not Validated'].map((status) => {
@@ -602,6 +600,12 @@ export default function RequirementsPage() {
'Partially Approved': isSelected ? 'bg-yellow-100 border-yellow-500 text-yellow-800' : 'bg-white border-gray-300 text-gray-600 hover:border-yellow-400', 'Partially Approved': isSelected ? 'bg-yellow-100 border-yellow-500 text-yellow-800' : 'bg-white border-gray-300 text-gray-600 hover:border-yellow-400',
'Not Validated': isSelected ? 'bg-gray-200 border-gray-500 text-gray-800' : 'bg-white border-gray-300 text-gray-600 hover:border-gray-400', 'Not Validated': isSelected ? 'bg-gray-200 border-gray-500 text-gray-800' : 'bg-white border-gray-300 text-gray-600 hover:border-gray-400',
} }
const statusTranslations: Record<string, string> = {
'Approved': t('filters.statuses.approved'),
'Denied': t('filters.statuses.denied'),
'Partially Approved': t('filters.statuses.partiallyApproved'),
'Not Validated': t('filters.statuses.notValidated'),
}
return ( return (
<button <button
key={status} key={status}
@@ -615,7 +619,7 @@ export default function RequirementsPage() {
}} }}
className={`px-3 py-1.5 rounded-full text-sm font-medium border-2 transition-colors ${statusStyles[status]}`} className={`px-3 py-1.5 rounded-full text-sm font-medium border-2 transition-colors ${statusStyles[status]}`}
> >
{status} {statusTranslations[status]}
</button> </button>
) )
})} })}
@@ -625,18 +629,18 @@ export default function RequirementsPage() {
{/* Order By */} {/* Order By */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Order by:</span> <span className="text-sm text-gray-600">{t('orderBy.label')}:</span>
<select <select
value={orderBy} value={orderBy}
onChange={(e) => setOrderBy(e.target.value as 'Date' | 'Priority' | 'Name')} onChange={(e) => setOrderBy(e.target.value as 'Date' | 'Priority' | 'Name')}
className="px-3 py-1 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500" className="px-3 py-1 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
> >
<option value="Date">Date</option> <option value="Date">{t('orderBy.date')}</option>
<option value="Priority">Priority</option> <option value="Priority">{t('orderBy.priority')}</option>
<option value="Name">Name</option> <option value="Name">{t('orderBy.name')}</option>
</select> </select>
<button className="px-4 py-1 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50"> <button className="px-4 py-1 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50">
Filter {tCommon('filter')}
</button> </button>
</div> </div>
@@ -644,12 +648,12 @@ export default function RequirementsPage() {
<button <button
onClick={() => setShowCaptionModal(true)} onClick={() => setShowCaptionModal(true)}
className="flex items-center gap-2 px-3 py-1.5 border border-teal-500 rounded text-sm font-medium text-teal-700 hover:bg-teal-50 transition-colors" className="flex items-center gap-2 px-3 py-1.5 border border-teal-500 rounded text-sm font-medium text-teal-700 hover:bg-teal-50 transition-colors"
title="View tag captions" title={t('caption.viewCaptions')}
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
Caption {t('caption.title')}
</button> </button>
</div> </div>
@@ -678,9 +682,9 @@ export default function RequirementsPage() {
{isDraft && ( {isDraft && (
<span <span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 border border-amber-300 flex-shrink-0" className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 border border-amber-300 flex-shrink-0"
title="This requirement is still in draft and not finalized" title={t('requirement.draftTooltip')}
> >
📝 Draft 📝 {t('requirement.draft')}
</span> </span>
)} )}
<span <span
@@ -711,13 +715,13 @@ export default function RequirementsPage() {
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600" className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600"
title={req.groups.slice(2).map(g => g.group_name).join(', ')} title={req.groups.slice(2).map(g => g.group_name).join(', ')}
> >
+{req.groups.length - 2} more +{req.groups.length - 2} {t('requirement.more')}
</span> </span>
)} )}
</> </>
) : ( ) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500"> <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500">
No groups {t('requirement.noGroups')}
</span> </span>
)} )}
</div> </div>
@@ -730,22 +734,22 @@ export default function RequirementsPage() {
{validationStatus} {validationStatus}
</span> </span>
{isStale && ( {isStale && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800" title="Requirement was modified after validation"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800" title={t('requirement.staleTooltip')}>
Stale {t('requirement.stale')}
</span> </span>
)} )}
</div> </div>
{req.validated_by && ( {req.validated_by && (
<p className="text-xs text-gray-500 mt-1 text-center truncate" title={req.validated_by}> <p className="text-xs text-gray-500 mt-1 text-center truncate" title={req.validated_by}>
by {req.validated_by} {t('requirement.by')} {req.validated_by}
</p> </p>
)} )}
</div> </div>
{/* Priority and Version */} {/* Priority and Version */}
<div className="w-[120px] lg:w-[140px] flex-shrink-0 px-3 py-3"> <div className="w-[120px] lg:w-[140px] flex-shrink-0 px-3 py-3">
<p className="text-sm text-gray-700 whitespace-nowrap">Priority: {priorityName}</p> <p className="text-sm text-gray-700 whitespace-nowrap">{t('requirement.priority')}: {priorityName}</p>
<p className="text-sm text-gray-600">Version: {req.version}</p> <p className="text-sm text-gray-600">{t('requirement.version')}: {req.version}</p>
</div> </div>
{/* Spacer to push buttons to the right */} {/* Spacer to push buttons to the right */}
@@ -757,14 +761,14 @@ export default function RequirementsPage() {
onClick={() => handleDetails(req.id)} onClick={() => handleDetails(req.id)}
className="px-3 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50 whitespace-nowrap" className="px-3 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50 whitespace-nowrap"
> >
Details {tCommon('details')}
</button> </button>
{!isAuditor && ( {!isAuditor && (
<button <button
onClick={() => openDeleteModal(req.id, req.req_name)} onClick={() => openDeleteModal(req.id, req.req_name)}
className="px-3 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50 whitespace-nowrap" className="px-3 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50 whitespace-nowrap"
> >
Remove {tCommon('remove')}
</button> </button>
)} )}
</div> </div>
@@ -774,7 +778,7 @@ export default function RequirementsPage() {
{sortedRequirements.length === 0 && ( {sortedRequirements.length === 0 && (
<div className="text-center py-12 text-gray-500"> <div className="text-center py-12 text-gray-500">
No requirements found matching your criteria. {t('noRequirementsFound')}
</div> </div>
)} )}
</div> </div>
@@ -790,7 +794,7 @@ export default function RequirementsPage() {
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg> </svg>
<h2 className="text-lg font-semibold text-gray-800">Deleted Requirements</h2> <h2 className="text-lg font-semibold text-gray-800">{t('deletedPanel.title')}</h2>
</div> </div>
<button <button
onClick={() => setShowDeletedPanel(false)} onClick={() => setShowDeletedPanel(false)}
@@ -813,8 +817,8 @@ export default function RequirementsPage() {
<svg className="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<p>No deleted requirements found.</p> <p>{t('deletedPanel.noDeleted')}</p>
<p className="text-sm mt-1">Deleted requirements will appear here.</p> <p className="text-sm mt-1">{t('deletedPanel.deletedWillAppear')}</p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@@ -834,7 +838,7 @@ export default function RequirementsPage() {
</span> </span>
</div> </div>
<h4 className="font-medium text-gray-800 truncate"> <h4 className="font-medium text-gray-800 truncate">
{req.req_name || 'Unnamed Requirement'} {req.req_name || t('deletedPanel.unnamed')}
</h4> </h4>
{req.req_desc && ( {req.req_desc && (
<p className="text-sm text-gray-500 mt-1 line-clamp-2"> <p className="text-sm text-gray-500 mt-1 line-clamp-2">
@@ -847,17 +851,17 @@ export default function RequirementsPage() {
<div className="mt-3 pt-3 border-t border-gray-200 text-xs text-gray-500 space-y-1"> <div className="mt-3 pt-3 border-t border-gray-200 text-xs text-gray-500 space-y-1">
{req.priority_name && ( {req.priority_name && (
<p> <p>
<span className="text-gray-400">Priority:</span>{' '} <span className="text-gray-400">{t('requirement.priority')}:</span>{' '}
<span className="font-medium">{req.priority_name}</span> <span className="font-medium">{req.priority_name}</span>
</p> </p>
)} )}
<p> <p>
<span className="text-gray-400">Original ID:</span>{' '} <span className="text-gray-400">{t('deletedPanel.originalId')}:</span>{' '}
<span className="font-medium">#{req.original_req_id}</span> <span className="font-medium">#{req.original_req_id}</span>
</p> </p>
{req.deleted_at && ( {req.deleted_at && (
<p> <p>
<span className="text-gray-400">Deleted:</span>{' '} <span className="text-gray-400">{t('deletedPanel.deletedAt')}:</span>{' '}
<span className="font-medium"> <span className="font-medium">
{new Date(req.deleted_at).toLocaleDateString('en-US', { {new Date(req.deleted_at).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
@@ -884,7 +888,7 @@ export default function RequirementsPage() {
{/* Panel Footer */} {/* Panel Footer */}
<div className="px-6 py-3 border-t border-gray-200 bg-gray-50 text-xs text-gray-500"> <div className="px-6 py-3 border-t border-gray-200 bg-gray-50 text-xs text-gray-500">
<p>💡 Deleted requirements are preserved in history for auditing purposes.</p> <p>💡 {t('deletedPanel.footerNote')}</p>
</div> </div>
</div> </div>
)} )}
@@ -907,7 +911,7 @@ export default function RequirementsPage() {
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg> </svg>
<h2 className="text-lg font-semibold text-gray-800">Delete Requirement</h2> <h2 className="text-lg font-semibold text-gray-800">{t('deleteModal.title')}</h2>
</div> </div>
<button <button
onClick={closeDeleteModal} onClick={closeDeleteModal}
@@ -923,11 +927,11 @@ export default function RequirementsPage() {
{/* Modal Body */} {/* Modal Body */}
<div className="px-6 py-6"> <div className="px-6 py-6">
<p className="text-gray-700"> <p className="text-gray-700">
Are you sure you want to delete the requirement{' '} {t('deleteModal.confirmMessage')}{' '}
<span className="font-semibold text-gray-900">"{deleteTarget.name}"</span>? <span className="font-semibold text-gray-900">"{deleteTarget.name}"</span>?
</p> </p>
<p className="text-sm text-gray-500 mt-2"> <p className="text-sm text-gray-500 mt-2">
This action will move the requirement to the deleted items. You can view deleted requirements from the "Deleted" panel. {t('deleteModal.explanation')}
</p> </p>
</div> </div>
@@ -938,14 +942,14 @@ export default function RequirementsPage() {
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100" className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
disabled={deleteLoading} disabled={deleteLoading}
> >
Cancel {tCommon('cancel')}
</button> </button>
<button <button
onClick={handleConfirmDelete} onClick={handleConfirmDelete}
className="px-4 py-2 bg-red-600 text-white rounded text-sm font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 bg-red-600 text-white rounded text-sm font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={deleteLoading} disabled={deleteLoading}
> >
{deleteLoading ? 'Deleting...' : 'Delete'} {deleteLoading ? t('deleteModal.deleting') : tCommon('delete')}
</button> </button>
</div> </div>
</div> </div>
@@ -962,7 +966,7 @@ export default function RequirementsPage() {
<svg className="w-5 h-5 text-teal-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-teal-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<h2 className="text-lg font-semibold text-gray-800">Tag Caption</h2> <h2 className="text-lg font-semibold text-gray-800">{t('caption.title')}</h2>
</div> </div>
<button <button
onClick={() => setShowCaptionModal(false)} onClick={() => setShowCaptionModal(false)}
@@ -992,7 +996,7 @@ export default function RequirementsPage() {
onClick={() => setShowCaptionModal(false)} onClick={() => setShowCaptionModal(false)}
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700" className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700"
> >
Close {tCommon('close')}
</button> </button>
</div> </div>
</div> </div>
@@ -1005,7 +1009,7 @@ export default function RequirementsPage() {
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4"> <div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
{/* Modal Header */} {/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800">New Requirement</h2> <h2 className="text-xl font-semibold text-gray-800">{t('createModal.title')}</h2>
<button <button
onClick={closeCreateModal} onClick={closeCreateModal}
className="text-gray-400 hover:text-gray-600" className="text-gray-400 hover:text-gray-600"
@@ -1029,7 +1033,7 @@ export default function RequirementsPage() {
{/* Tag Selection */} {/* Tag Selection */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Tag <span className="text-red-500">*</span> {t('createModal.tag')} <span className="text-red-500">*</span>
</label> </label>
<select <select
value={newReqTagId} value={newReqTagId}
@@ -1037,7 +1041,7 @@ export default function RequirementsPage() {
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent" className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
required required
> >
<option value="">Select a tag...</option> <option value="">{t('createModal.selectTag')}</option>
{tags.map((tag) => ( {tags.map((tag) => (
<option key={tag.id} value={tag.id}> <option key={tag.id} value={tag.id}>
{tag.tag_code} - {tag.tag_description} {tag.tag_code} - {tag.tag_description}
@@ -1049,13 +1053,13 @@ export default function RequirementsPage() {
{/* Requirement Name */} {/* Requirement Name */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Name <span className="text-red-500">*</span> {t('createModal.name')} <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
value={newReqName} value={newReqName}
onChange={(e) => setNewReqName(e.target.value)} onChange={(e) => setNewReqName(e.target.value)}
placeholder="Enter requirement name" placeholder={t('createModal.namePlaceholder')}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent" className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
required required
/> />
@@ -1064,12 +1068,12 @@ export default function RequirementsPage() {
{/* Description */} {/* Description */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Description {t('createModal.description')}
</label> </label>
<textarea <textarea
value={newReqDesc} value={newReqDesc}
onChange={(e) => setNewReqDesc(e.target.value)} onChange={(e) => setNewReqDesc(e.target.value)}
placeholder="Enter requirement description (optional)" placeholder={t('createModal.descriptionPlaceholder')}
rows={3} rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent resize-none" className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent resize-none"
/> />
@@ -1078,14 +1082,14 @@ export default function RequirementsPage() {
{/* Priority Selection */} {/* Priority Selection */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Priority {t('createModal.priority')}
</label> </label>
<select <select
value={newReqPriorityId} value={newReqPriorityId}
onChange={(e) => setNewReqPriorityId(e.target.value ? Number(e.target.value) : '')} onChange={(e) => setNewReqPriorityId(e.target.value ? Number(e.target.value) : '')}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent" className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
> >
<option value="">Select a priority (optional)...</option> <option value="">{t('createModal.selectPriority')}</option>
{priorities.map((priority) => ( {priorities.map((priority) => (
<option key={priority.id} value={priority.id}> <option key={priority.id} value={priority.id}>
{priority.priority_name} ({priority.priority_num}) {priority.priority_name} ({priority.priority_num})
@@ -1097,7 +1101,7 @@ export default function RequirementsPage() {
{/* Groups Selection */} {/* Groups Selection */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Groups {t('createModal.groups')}
</label> </label>
<div className="grid grid-cols-2 gap-2 max-h-40 overflow-y-auto border border-gray-200 rounded p-3"> <div className="grid grid-cols-2 gap-2 max-h-40 overflow-y-auto border border-gray-200 rounded p-3">
{groups.map((group) => ( {groups.map((group) => (
@@ -1123,14 +1127,14 @@ export default function RequirementsPage() {
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100" className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
disabled={createLoading} disabled={createLoading}
> >
Cancel {tCommon('cancel')}
</button> </button>
<button <button
type="submit" type="submit"
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={createLoading} disabled={createLoading}
> >
{createLoading ? 'Creating...' : 'Create Requirement'} {createLoading ? t('createModal.creating') : t('createModal.createButton')}
</button> </button>
</div> </div>
</form> </form>