Initial commit of Sidrah Audit App

This commit is contained in:
Arun Reddy 2026-10-09 12:16:48 +05:30
commit 93b9781aeb
335 changed files with 53740 additions and 0 deletions

21
.editorconfig Normal file
View File

@ -0,0 +1,21 @@
# Root editor config file
root = true
# Common settings
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
# python, js indentation settings
[{*.py,*.js,*.vue,*.css,*.scss,*.html}]
indent_style = tab
indent_size = 4
max_line_length = 99
# JSON files - mostly doctype schema files
[{*.json}]
insert_final_newline = false
indent_style = space
indent_size = 2

124
.eslintrc Normal file
View File

@ -0,0 +1,124 @@
{
"env": {
"browser": true,
"node": true,
"es2022": true
},
"parserOptions": {
"sourceType": "module"
},
"extends": "eslint:recommended",
"rules": {
"indent": "off",
"brace-style": "off",
"no-mixed-spaces-and-tabs": "off",
"no-useless-escape": "off",
"space-unary-ops": ["error", { "words": true }],
"linebreak-style": "off",
"quotes": ["off"],
"semi": "off",
"camelcase": "off",
"no-unused-vars": "off",
"no-console": ["warn"],
"no-extra-boolean-cast": ["off"],
"no-control-regex": ["off"],
},
"root": true,
"globals": {
"frappe": true,
"Vue": true,
"SetVueGlobals": true,
"__": true,
"repl": true,
"Class": true,
"locals": true,
"cint": true,
"cstr": true,
"cur_frm": true,
"cur_dialog": true,
"cur_page": true,
"cur_list": true,
"cur_tree": true,
"msg_dialog": true,
"is_null": true,
"in_list": true,
"has_common": true,
"posthog": true,
"has_words": true,
"validate_email": true,
"open_web_template_values_editor": true,
"validate_name": true,
"validate_phone": true,
"validate_url": true,
"get_number_format": true,
"format_number": true,
"format_currency": true,
"comment_when": true,
"open_url_post": true,
"toTitle": true,
"lstrip": true,
"rstrip": true,
"strip": true,
"strip_html": true,
"replace_all": true,
"flt": true,
"precision": true,
"CREATE": true,
"AMEND": true,
"CANCEL": true,
"copy_dict": true,
"get_number_format_info": true,
"strip_number_groups": true,
"print_table": true,
"Layout": true,
"web_form_settings": true,
"$c": true,
"$a": true,
"$i": true,
"$bg": true,
"$y": true,
"$c_obj": true,
"refresh_many": true,
"refresh_field": true,
"toggle_field": true,
"get_field_obj": true,
"get_query_params": true,
"unhide_field": true,
"hide_field": true,
"set_field_options": true,
"getCookie": true,
"getCookies": true,
"get_url_arg": true,
"md5": true,
"$": true,
"jQuery": true,
"moment": true,
"hljs": true,
"Awesomplete": true,
"Sortable": true,
"Showdown": true,
"Taggle": true,
"Gantt": true,
"Slick": true,
"Webcam": true,
"PhotoSwipe": true,
"PhotoSwipeUI_Default": true,
"io": true,
"JsBarcode": true,
"L": true,
"Chart": true,
"DataTable": true,
"Cypress": true,
"cy": true,
"it": true,
"describe": true,
"expect": true,
"context": true,
"before": true,
"beforeEach": true,
"after": true,
"qz": true,
"localforage": true,
"extend_cscript": true
}
}

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.DS_Store
*.pyc
*.egg-info
*.swp
tags
node_modules
__pycache__

69
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,69 @@
exclude: 'node_modules|.git'
default_stages: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
files: "sidrah_business.*"
exclude: ".*json$|.*txt$|.*csv|.*md|.*svg"
- id: check-merge-conflict
- id: check-ast
- id: check-json
- id: check-toml
- id: check-yaml
- id: debug-statements
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.1
hooks:
- id: ruff
name: "Run ruff import sorter"
args: ["--select=I", "--fix"]
- id: ruff
name: "Run ruff linter"
- id: ruff-format
name: "Run ruff formatter"
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.7.1
hooks:
- id: prettier
types_or: [javascript, vue, scss]
# Ignore any files that might contain jinja / bundles
exclude: |
(?x)^(
sidrah_business/public/dist/.*|
.*node_modules.*|
.*boilerplate.*|
sidrah_business/templates/includes/.*|
sidrah_business/public/js/lib/.*
)$
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.44.0
hooks:
- id: eslint
types_or: [javascript]
args: ['--quiet']
# Ignore any files that might contain jinja / bundles
exclude: |
(?x)^(
sidrah_business/public/dist/.*|
cypress/.*|
.*node_modules.*|
.*boilerplate.*|
sidrah_business/templates/includes/.*|
sidrah_business/public/js/lib/.*
)$
ci:
autoupdate_schedule: weekly
skip: []
submodules: false

33
README.md Normal file
View File

@ -0,0 +1,33 @@
### Sidrah Business
Sidrah Business
### Installation
You can install this app using the [bench](https://github.com/frappe/bench) CLI:
```bash
cd $PATH_TO_YOUR_BENCH
bench get-app $URL_OF_THIS_REPO --branch develop
bench install-app sidrah_business
```
### Contributing
This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository:
```bash
cd apps/sidrah_business
pre-commit install
```
Pre-commit is configured to use the following tools for checking and formatting your code:
- ruff
- eslint
- prettier
- pyupgrade
### License
mit

1
frontend/.env Normal file
View File

@ -0,0 +1 @@
VITE_API_URL=http://172.19.127.96:8000

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

106
frontend/README.md Normal file
View File

@ -0,0 +1,106 @@
// README - Modular Sidrah Business App Structure
## 📁 FILE STRUCTURE
```
src/
├── App.tsx (MAIN - Login + Router)
├── components/
│ ├── Sidebar.tsx (Navigation)
│ ├── Header.tsx (Page Title/Subtitle)
│ └── common.tsx (Colors, Styles, Helpers)
├── pages/
│ ├── Dashboard.tsx (Overview Page)
│ ├── incidents/
│ │ ├── IncidentForm.tsx (Submit Form)
│ │ └── IncidentsList.tsx (View All)
│ ├── ideas/
│ │ ├── IdeaForm.tsx (Submit Form)
│ │ └── IdeasList.tsx (View All)
│ ├── orders/
│ │ ├── OrderForm.tsx (Submit Form)
│ │ └── OrdersList.tsx (View All)
│ └── organizations/
│ └── OrganizationsList.tsx (View All)
├── types/
│ └── index.ts (All Interfaces)
└── utils/
└── api.ts (API Calls)
```
## 🚀 SETUP INSTRUCTIONS
### 1. Create Folder Structure
```bash
mkdir -p src/{components,pages/{incidents,ideas,orders,organizations},types,utils}
```
### 2. Copy Files to Correct Locations
**Root Level:**
- `App.tsx` → `src/App.tsx`
**Components:**
- `components_Sidebar.tsx` → `src/components/Sidebar.tsx`
- `components_Header.tsx` → `src/components/Header.tsx`
- `components_common.tsx` → `src/components/common.tsx`
**Types:**
- `types_index.ts` → `src/types/index.ts`
**Utils:**
- `utils_api.ts` → `src/utils/api.ts`
**Pages:**
- `pages_Dashboard.tsx` → `src/pages/Dashboard.tsx`
- `pages_incidents_IncidentForm.tsx` → `src/pages/incidents/IncidentForm.tsx`
- `pages_incidents_IncidentsList.tsx` → `src/pages/incidents/IncidentsList.tsx`
- `pages_ideas_IdeaForm.tsx` → `src/pages/ideas/IdeaForm.tsx`
- `pages_ideas_IdeasList.tsx` → `src/pages/ideas/IdeasList.tsx`
- `pages_orders_OrderForm.tsx` → `src/pages/orders/OrderForm.tsx`
- `pages_orders_OrdersList.tsx` → `src/pages/orders/OrdersList.tsx`
- `pages_organizations_OrganizationsList.tsx` → `src/pages/organizations/OrganizationsList.tsx`
## 📦 KEY BENEFITS
✅ **Modular** - Each page in its own file
✅ **Scalable** - Easy to add new pages
✅ **Maintainable** - Clear folder structure
✅ **Reusable** - Shared components & utilities
✅ **Type-Safe** - Centralized type definitions
✅ **Clean** - Separated concerns
## 🔧 HOW TO ADD NEW PAGE
1. Create folder: `src/pages/newfeature/`
2. Create component files:
- `NewFeatureForm.tsx`
- `NewFeatureList.tsx`
3. Import in `App.tsx`
4. Add to Sidebar navigation
5. Done! 🎉
## 📝 EDIT COLORS
Edit `src/components/common.tsx`:
```tsx
export const colors = {
primary: '#3b82f6', // Change this
secondary: '#10b981',
// ... etc
}
```
## 🔒 ENABLE ROLE RESTRICTIONS
In `src/App.tsx`, uncomment lines to enable roles:
```tsx
// const isManager = user.role === 'Manager'
// const isReadOnly = user.role === 'Auditor'
```
Then pass to page components to conditionally render.
---
**All modular, organized, production-ready!** 🚀

28
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

16
frontend/index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sidrah</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3327
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
frontend/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@types/node": "^26.0.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"eslint": "^9.13.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"globals": "^15.11.0",
"typescript": "~5.6.2",
"typescript-eslint": "^8.11.0",
"vite": "^5.4.10"
}
}

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
frontend/src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

1485
frontend/src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

612
frontend/src/api/index.ts Normal file
View File

@ -0,0 +1,612 @@
import { API_BASE_URL } from '../constants'
const SB_DOCTYPES = new Set([
'SB Incident', 'SB Idea', 'SB Order', 'SB Process',
'SB Organization', 'SB Cycle', 'SB KPI Input',
'Managements', 'Department', 'Branch',
'Incident Types', 'Idea Types', 'Order Types', 'Process Types',
'Incident Class', 'Incident Field',
'Incident Statuses', 'Idea Statuses', 'Order Statuses', 'Process Statuses',
'SB Group Company', 'User',
])
let csrfToken = ''
export async function fetchCSRFToken(): Promise<string> {
try {
const res = await fetch(`${API_BASE_URL}/api/method/sidrah_business.api.get_csrf_token`, {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' }
})
if (res.ok) {
const json = await res.json()
const token: string = json.message || ''
if (token) {
csrfToken = token
return token
}
}
} catch (e) {
console.error('CSRF fetch error:', e)
}
return csrfToken
}
async function ensureCSRF(): Promise<string> {
if (!csrfToken) await fetchCSRFToken()
return csrfToken
}
function extractFrappeErrorMessage(j: any, fallback: string): string {
try {
if (j?._server_messages) {
const messages = JSON.parse(j._server_messages)
if (messages.length) {
const first = JSON.parse(messages[0])
if (first?.message) return first.message.replace(/<[^>]+>/g, '').trim()
}
}
} catch { /* fall through to fallback */ }
let msg = j?.message || j?.exception || j?.exc || fallback
if (typeof msg === 'string') {
if (msg.includes('frappe.exceptions.')) {
const lines = msg.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('Traceback') && !l.startsWith('File '))
const lastLine = lines[lines.length - 1] || msg
msg = lastLine.replace(/^frappe\.exceptions\.\w+:\s*/, '').trim()
}
}
return msg || fallback
}
export async function getResource(doctype: string, name: string, fields: string[] = []): Promise<any> {
// SB doctypes use permission-bypassing endpoint
if (SB_DOCTYPES.has(doctype)) {
const url = `${API_BASE_URL}/api/method/sidrah_business.api.get_sb_doc?doctype=${encodeURIComponent(doctype)}&name=${encodeURIComponent(name)}`
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } })
if (!res.ok) throw new Error(`API Error: ${res.status}`)
const json = await res.json()
return json.message
}
const query = fields.length ? `?fields=${encodeURIComponent(JSON.stringify(fields))}` : ''
const res = await fetch(`${API_BASE_URL}/api/v1/resource/${doctype}/${encodeURIComponent(name)}${query}`, {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' }
})
if (!res.ok) throw new Error(`API Error: ${res.status}`)
const json = await res.json()
return json.data
}
export async function deleteResource(doctype: string, name: string, reason: string = '', permanent: boolean = false): Promise<any> {
const token = await ensureCSRF()
if (SB_DOCTYPES.has(doctype)) {
const params = new URLSearchParams({ doctype, name })
if (reason) params.append('reason', reason)
if (permanent) params.append('permanent', '1')
const res = await fetch(`${API_BASE_URL}/api/method/sidrah_business.api.delete_sb_doc`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'X-Frappe-CSRF-Token': token },
body: params.toString(),
})
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(extractFrappeErrorMessage(j, `Error deleting ${name}`)) }
return
}
const params = new URLSearchParams({ doctype, name })
if (reason) params.append('reason', reason)
if (permanent) params.append('permanent', '1')
const res = await fetch(`${API_BASE_URL}/api/method/sidrah_business.soft_delete.soft_delete`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'X-Frappe-CSRF-Token': token },
body: params.toString(),
})
if (!res.ok) {
const j = await res.json().catch(() => ({}))
throw new Error(extractFrappeErrorMessage(j, `Error deleting ${name}`))
}
return await res.json()
}
export async function restoreResource(doctype: string, name: string): Promise<any> {
const token = await ensureCSRF()
if (SB_DOCTYPES.has(doctype)) {
const params = new URLSearchParams({ doctype, name })
const res = await fetch(`${API_BASE_URL}/api/method/sidrah_business.api.restore_sb_doc`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'X-Frappe-CSRF-Token': token },
body: params.toString(),
})
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(extractFrappeErrorMessage(j, `Error restoring ${name}`)) }
const json = await res.json()
return json.message || { success: true }
}
const params = new URLSearchParams({ doctype, name })
const res = await fetch(`${API_BASE_URL}/api/method/sidrah_business.soft_delete.restore_doc`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'X-Frappe-CSRF-Token': token },
body: params.toString(),
})
if (!res.ok) {
const j = await res.json().catch(() => ({}))
throw new Error(extractFrappeErrorMessage(j, `Error restoring ${name}`))
}
return await res.json()
}
export async function get(doctype: string): Promise<any[]> {
if (SB_DOCTYPES.has(doctype)) {
try {
const url = `${API_BASE_URL}/api/method/sidrah_business.api.get_sb_list?doctype=${encodeURIComponent(doctype)}&limit=500`
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } })
if (!res.ok) return []
const json = await res.json()
return json.message || []
} catch { return [] }
}
const fields = encodeURIComponent('["*"]')
const url = `${API_BASE_URL}/api/v1/resource/${doctype}?limit_page_length=500&fields=${fields}`
try {
const res = await fetch(url, { method: 'GET', credentials: 'include', headers: { Accept: 'application/json' } })
if (res.status === 401 || res.status === 403) return []
if (!res.ok) return []
const json = await res.json()
return json.data || []
} catch (e) {
console.error('Get error:', e)
return []
}
}
export async function post(doctype: string, data: Record<string, any>): Promise<any> {
const token = await ensureCSRF()
if (SB_DOCTYPES.has(doctype)) {
const params = new URLSearchParams({ doctype, data_json: JSON.stringify(data) })
const res = await fetch(`${API_BASE_URL}/api/method/sidrah_business.api.create_sb_doc`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'X-Frappe-CSRF-Token': token },
body: params.toString(),
})
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.message || j.exc || `Error creating ${doctype}`) }
const j = await res.json()
return { data: j.message }
}
const url = `${API_BASE_URL}/api/v1/resource/${doctype}`
try {
const res = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Frappe-CSRF-Token': token },
body: JSON.stringify(data)
})
if (res.status === 401 || res.status === 403) throw new Error('Permission denied - login required')
if (!res.ok) { const json = await res.json(); throw new Error(json.message || json.exc || `API Error: ${res.status}`) }
return await res.json()
} catch (e) { console.error('Post error:', e); throw e }
}
export async function updateDoc(doctype: string, name: string, data: Record<string, any>): Promise<any> {
const token = await ensureCSRF()
if (SB_DOCTYPES.has(doctype)) {
const params = new URLSearchParams({ doctype, name, data_json: JSON.stringify(data) })
const res = await fetch(`${API_BASE_URL}/api/method/sidrah_business.api.update_sb_doc`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'X-Frappe-CSRF-Token': token },
body: params.toString(),
})
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.message || j.exc || `Error updating ${doctype}`) }
const j = await res.json()
return { data: j.message }
}
const url = `${API_BASE_URL}/api/v1/resource/${doctype}/${encodeURIComponent(name)}`
try {
const res = await fetch(url, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Frappe-CSRF-Token': token },
body: JSON.stringify(data)
})
if (res.status === 401 || res.status === 403) throw new Error('Permission denied - login required')
if (!res.ok) { const json = await res.json(); throw new Error(json.message || 'Update Error') }
return await res.json()
} catch (e) { console.error('Update error:', e); throw e }
}
export async function getUsers(): Promise<any[]> {
const url = `${API_BASE_URL}/api/method/sidrah_business.api.get_users`
try {
const res = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' }
})
if (res.status === 401 || res.status === 403) return []
if (!res.ok) return []
const json = await res.json()
return json.message || json.data || []
} catch (e) {
console.error('Get users error:', e)
return []
}
}
export async function loginApi(usr: string, pwd: string): Promise<any> {
const form = new FormData()
form.append('usr', usr)
form.append('pwd', pwd)
const res = await fetch(`${API_BASE_URL}/api/method/login`, {
method: 'POST',
credentials: 'include',
body: form
})
const data = await res.json()
if (data.csrf_token) {
csrfToken = data.csrf_token
} else {
await fetchCSRFToken()
}
return data
}
export async function logoutApi(): Promise<void> {
const token = await ensureCSRF()
try {
await fetch(`${API_BASE_URL}/api/method/logout`, {
method: 'POST',
credentials: 'include',
headers: {
'X-Frappe-CSRF-Token': token,
Accept: 'application/json'
}
})
} catch {
// Ignore logout errors — clear local state regardless
}
csrfToken = ''
}
export function setCSRF(token: string): void {
csrfToken = token
}
export function getCsrfToken(): string {
return csrfToken
}
export async function getSessionUser(): Promise<string> {
try {
const res = await fetch(`${API_BASE_URL}/api/method/frappe.auth.get_logged_user`, {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' }
})
if (!res.ok) return ''
const json = await res.json()
return json.message || ''
} catch {
return ''
}
}
export async function getSBUsers(): Promise<any[]> {
const filters = JSON.stringify([['name','not in',['Administrator','Guest']]])
const url = `${API_BASE_URL}/api/v1/resource/User?limit_page_length=500&fields=${encodeURIComponent('["*"]')}&filters=${encodeURIComponent(filters)}`
try {
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } })
if (!res.ok) return []
const json = await res.json()
return json.data || []
} catch { return [] }
}
export async function getSBUserRoleMap(): Promise<Record<string, string[]>> {
const url = `${API_BASE_URL}/api/method/sidrah_business.api.get_user_role_map`
try {
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } })
if (!res.ok) return {}
const json = await res.json()
return json.message || {}
} catch { return {} }
}
export async function setUserPassword(userName: string, newPassword: string): Promise<void> {
const token = await ensureCSRF()
try {
const params = new URLSearchParams({ user: userName, password: newPassword })
const res = await fetch(`${API_BASE_URL}/api/method/sidrah_business.api.set_user_password`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'X-Frappe-CSRF-Token': token },
body: params.toString(),
})
if (!res.ok) {
await updateDoc('User', userName, { new_password: newPassword })
}
} catch {
await updateDoc('User', userName, { new_password: newPassword })
}
}
export async function getNotifications(limit = 20): Promise<any[]> {
try {
const url = `${API_BASE_URL}/api/method/sidrah_business.api.get_notifications?limit=${limit}`
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } })
if (!res.ok) return []
const json = await res.json()
return json.message || []
} catch { return [] }
}
export async function markAllNotificationsRead(): Promise<void> {
const token = await ensureCSRF()
try {
await fetch(`${API_BASE_URL}/api/method/sidrah_business.api.mark_notifications_read`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'X-Frappe-CSRF-Token': token }
})
} catch { /* non-critical */ }
}
export async function markNotificationRead(name: string): Promise<void> {
const token = await ensureCSRF()
try {
await fetch(`${API_BASE_URL}/api/method/sidrah_business.api.mark_notification_read`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'X-Frappe-CSRF-Token': token },
body: new URLSearchParams({ name }).toString(),
})
} catch { /* non-critical */ }
}
export async function disableUserApi(name: string): Promise<void> {
const token = await ensureCSRF()
const params = new URLSearchParams({ name })
await fetch(`${API_BASE_URL}/api/method/sidrah_business.api.disable_user`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'X-Frappe-CSRF-Token': token },
body: params.toString(),
})
}
export async function getComments(doctype: string, docname: string): Promise<any[]> {
try {
const url = `${API_BASE_URL}/api/method/sidrah_business.api.get_doc_comments?doctype=${encodeURIComponent(doctype)}&docname=${encodeURIComponent(docname)}`
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } })
if (!res.ok) return []
const json = await res.json()
return json.message || []
} catch { return [] }
}
export async function postComment(doctype: string, docname: string, content: string): Promise<any> {
const token = await ensureCSRF()
const params = new URLSearchParams({ doctype, docname, content })
const res = await fetch(`${API_BASE_URL}/api/method/sidrah_business.api.post_doc_comment`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'X-Frappe-CSRF-Token': token },
body: params.toString(),
})
if (!res.ok) throw new Error('Failed to post comment')
const json = await res.json()
return json.message
}
export async function getActivityLog(doctype: string, docname: string): Promise<any[]> {
try {
const url = `${API_BASE_URL}/api/method/sidrah_business.api.get_doc_versions?doctype=${encodeURIComponent(doctype)}&docname=${encodeURIComponent(docname)}`
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } })
if (!res.ok) return []
const json = await res.json()
return json.message || []
} catch { return [] }
}
export async function updateProcessDocStatus(name: string, status: string): Promise<void> {
const token = await ensureCSRF()
const params = new URLSearchParams({ name, status })
const res = await fetch(`${API_BASE_URL}/api/method/sidrah_business.api.update_process_doc_status`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'X-Frappe-CSRF-Token': token },
body: params.toString(),
})
if (!res.ok) {
const json = await res.json().catch(() => ({}))
throw new Error(json.message || 'Failed to update status')
}
}
export async function getUserRoles(user: string): Promise<string[]> {
try {
const url = `${API_BASE_URL}/api/method/sidrah_business.api.get_user_roles?user=${encodeURIComponent(user)}`
const res = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' }
})
if (!res.ok) return []
const json = await res.json()
return json.message || []
} catch {
return []
}
}
export async function getMyOrganization(): Promise<string> {
try {
const url = `${API_BASE_URL}/api/method/sidrah_business.api.get_my_organization`
const res = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' }
})
if (!res.ok) return ''
const json = await res.json()
return json.message || ''
} catch {
return ''
}
}
async function callMethod(method: string, params: Record<string, string> = {}): Promise<any> {
const token = await ensureCSRF()
const body = new URLSearchParams(params)
const res = await fetch(`${API_BASE_URL}/api/method/sidrah_business.api.${method}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'X-Frappe-CSRF-Token': token },
body: body.toString(),
})
if (!res.ok) {
const json = await res.json().catch(() => ({}))
throw new Error(extractFrappeErrorMessage(json, `Error calling ${method}`))
}
const json = await res.json()
return json.message
}
export async function applyOrderWorkflow(name: string, action: string, comment = '', stage?: string, status?: string, cycleStatus?: string): Promise<any> {
const params: Record<string, string> = { name, action, comment }
if (stage !== undefined) params.stage = stage
if (status !== undefined) params.status = status
if (cycleStatus !== undefined) params.cycle_status = cycleStatus
return callMethod('apply_order_workflow', params)
}
export async function applyIncidentWorkflow(name: string, action: string, fieldsJson = ''): Promise<any> {
return callMethod('apply_incident_workflow', { name, action, fields_json: fieldsJson })
}
export async function applyIdeaWorkflow(name: string, action: string, fieldsJson = ''): Promise<any> {
return callMethod('apply_idea_workflow', { name, action, fields_json: fieldsJson })
}
export async function getDashboardData(organization = ''): Promise<any> {
const url = `${API_BASE_URL}/api/method/sidrah_business.api.get_dashboard_data${organization ? `?organization=${encodeURIComponent(organization)}` : ''}`
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } })
if (!res.ok) return { escalated_processes: [], escalated_orders: [], untriaged_incidents: [], kpi_inputs: [] }
const json = await res.json()
return json.message || {}
}
export async function getReportData(reportName: string, filters: Record<string, string> = {}): Promise<any> {
return callMethod('get_report_data', { report_name: reportName, filters_json: JSON.stringify(filters) })
}
export async function getInboxItems(): Promise<any[]> {
try {
const url = `${API_BASE_URL}/api/method/sidrah_business.api.get_inbox_items`
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } })
if (!res.ok) return []
const json = await res.json()
return json.message || []
} catch { return [] }
}
export async function createDelegation(manager: string, functions: string[], organization = '', remarks = '', incident_name = '', idea_name = ''): Promise<any> {
return callMethod('create_delegation', { manager, functions_json: JSON.stringify(functions), organization, remarks, incident_name, idea_name })
}
export async function respondToDelegation(name: string, response: 'Accepted' | 'Rejected', remarks = ''): Promise<any> {
return callMethod('respond_to_delegation', { name, response, remarks })
}
export async function revokeDelegation(name: string, remarks = ''): Promise<any> {
return callMethod('revoke_delegation', { name, remarks })
}
export async function getDelegations(): Promise<any[]> {
try {
const url = `${API_BASE_URL}/api/method/sidrah_business.api.get_delegations`
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } })
if (!res.ok) return []
const json = await res.json()
return json.message || []
} catch { return [] }
}
export async function getDelegation(name: string): Promise<any> {
const url = `${API_BASE_URL}/api/method/sidrah_business.api.get_delegation?name=${encodeURIComponent(name)}`
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } })
if (!res.ok) throw new Error('Failed to load delegation')
const json = await res.json()
return json.message
}
export async function getMyDelegatedFunctions(): Promise<string[]> {
try {
const url = `${API_BASE_URL}/api/method/sidrah_business.api.get_my_delegated_functions`
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } })
if (!res.ok) return []
const json = await res.json()
return json.message || []
} catch { return [] }
}
// Per-function organization scope: null = unrestricted (all orgs), else the specific delegated org list
export async function getMyDelegatedOrganizations(): Promise<Record<string, string[] | null>> {
try {
const url = `${API_BASE_URL}/api/method/sidrah_business.api.get_my_delegated_organizations`
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } })
if (!res.ok) return {}
const json = await res.json()
return json.message || {}
} catch { return {} }
}
export async function getGroupCompanies(): Promise<any[]> {
try {
const token = await ensureCSRF()
const res = await fetch(`${API_BASE_URL}/api/method/sidrah_business.api.get_group_companies`, {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json', 'X-Frappe-CSRF-Token': token },
})
if (!res.ok) return []
const json = await res.json()
return json.message || []
} catch { return [] }
}
export async function saveGroupCompanyApi(data: any): Promise<any> {
const token = await ensureCSRF()
const params = new URLSearchParams()
for (const k in data) {
if (data[k] !== undefined && data[k] !== null) {
params.append(k, String(data[k]))
}
}
const res = await fetch(`${API_BASE_URL}/api/method/sidrah_business.api.save_group_company`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'X-Frappe-CSRF-Token': token },
body: params.toString(),
})
if (!res.ok) {
const j = await res.json().catch(() => ({}))
throw new Error(extractFrappeErrorMessage(j, 'Error saving Group Company'))
}
const json = await res.json()
return json.message
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,227 @@
import { useState, useEffect, useCallback } from 'react'
import { getActivityLog } from '../api'
import { useT } from '../i18n'
interface RawEntry {
name: string
owner: string
creation: string
data: string
}
interface Change { field: string; oldVal: string; newVal: string }
interface ParsedEntry {
name: string
owner: string
creation: string
changes: Change[]
added: number
removed: number
rowChanged: number
}
interface Props {
doctype: string
docname: string
docCreation?: string
docOwner?: string
}
function timeAgo(dateStr: string): string {
if (!dateStr) return ''
const diff = (Date.now() - new Date(dateStr).getTime()) / 1000
if (diff < 60) return `${Math.floor(diff)}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 86400 * 7) return `${Math.floor(diff / 86400)}d ago`
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
function fmtField(f: string): string {
return (f || '').replace(/^custom_/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function fmtVal(v: unknown): string {
if (v === null || v === undefined || v === '') return '(empty)'
if (typeof v === 'object') return JSON.stringify(v)
return String(v)
}
function userLabel(owner: string): string {
return (owner || 'Unknown').replace(/@.*/, '').replace(/[._]/g, ' ')
}
function userInitials(owner: string): string {
return userLabel(owner).split(' ').filter(Boolean).slice(0, 2).map(w => w[0].toUpperCase()).join('')
}
function parseEntry(entry: RawEntry): ParsedEntry {
let data: any = {}
try { data = JSON.parse(entry.data || '{}') } catch { /* ignore */ }
const changes: Change[] = (data.changed || []).map((c: any[]) => ({
field: c[0] || '',
oldVal: fmtVal(c[1]),
newVal: fmtVal(c[2]),
}))
return {
name: entry.name,
owner: entry.owner,
creation: entry.creation,
changes,
added: (data.added || []).length,
removed: (data.removed || []).length,
rowChanged: (data.row_changed || []).length,
}
}
export default function ActivityLog({ doctype, docname, docCreation, docOwner }: Props) {
const t = useT()
const [entries, setEntries] = useState<ParsedEntry[]>([])
const [loading, setLoading] = useState(false)
const [collapsed, setCollapsed] = useState(true)
const [showAll, setShowAll] = useState(false)
const INITIAL_SHOW = 5
const load = useCallback(async () => {
setLoading(true)
const data = await getActivityLog(doctype, docname)
setEntries(data.map(parseEntry))
setLoading(false)
}, [doctype, docname])
useEffect(() => {
if (!collapsed) load()
}, [collapsed, load])
const visible = showAll ? entries : entries.slice(0, INITIAL_SHOW)
const hidden = entries.length - INITIAL_SHOW
const dotStyle = (color: string, isFirst = false): React.CSSProperties => ({
position: 'absolute',
left: -14,
top: 14,
width: 10,
height: 10,
borderRadius: '50%',
background: color,
border: '2px solid white',
boxShadow: isFirst ? `0 0 0 2px ${color}44` : 'none',
})
return (
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'hidden' }}>
{/* Header */}
<div
onClick={() => setCollapsed(c => !c)}
style={{ padding: '12px 20px', borderBottom: collapsed ? 'none' : '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', userSelect: 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, color: '#1a2332' }}>
📋 {t.activity.title}
{!collapsed && entries.length > 0 && (
<span style={{ background: '#e8f4fd', color: '#2980b9', fontSize: 11, fontWeight: 700, borderRadius: 10, padding: '2px 8px' }}>
{entries.length}
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{!collapsed && (
<button
onClick={e => { e.stopPropagation(); load() }}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#aaa', fontSize: 16, padding: '2px 4px', lineHeight: 1 }}
title="Refresh">
↻
</button>
)}
<span style={{ color: '#ccc', fontSize: 11 }}>{collapsed ? '▼' : '▲'}</span>
</div>
</div>
{!collapsed && (
<div style={{ padding: '16px 20px' }}>
{loading && (
<div style={{ textAlign: 'center', color: '#aaa', fontSize: 13, padding: '16px 0' }}>{t.loading}</div>
)}
{!loading && (
<div style={{ position: 'relative', paddingLeft: 20 }}>
{/* Timeline vertical line */}
<div style={{ position: 'absolute', left: 8, top: 0, bottom: 0, width: 2, background: '#e5e7eb' }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{visible.map((entry, idx) => (
<div key={entry.name} style={{ position: 'relative', paddingLeft: 12 }}>
<div style={dotStyle(idx === 0 ? '#3498db' : '#d1d5db', idx === 0)} />
<div style={{ background: idx === 0 ? '#eff6ff' : '#f9fafb', border: `1px solid ${idx === 0 ? '#bfdbfe' : '#e5e7eb'}`, borderRadius: 8, padding: '10px 14px' }}>
{/* Entry header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<div style={{ width: 26, height: 26, borderRadius: '50%', background: '#3498db', color: 'white', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, fontWeight: 700, flexShrink: 0 }}>
{userInitials(entry.owner)}
</div>
<span style={{ fontSize: 12, fontWeight: 600, color: '#374151' }}>{userLabel(entry.owner)}</span>
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 'auto' }}>{timeAgo(entry.creation)}</span>
</div>
{/* Changes */}
{entry.changes.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{entry.changes.map((ch, i) => (
<div key={i} style={{ fontSize: 12, lineHeight: 1.5 }}>
<span style={{ fontWeight: 600, color: '#6d28d9' }}>{fmtField(ch.field)}</span>
<span style={{ color: '#6b7280' }}> changed from </span>
<span style={{ padding: '1px 5px', background: '#fee2e2', color: '#dc2626', borderRadius: 3, fontSize: 11, fontFamily: 'monospace' }}>{ch.oldVal}</span>
<span style={{ color: '#6b7280' }}> → </span>
<span style={{ padding: '1px 5px', background: '#dcfce7', color: '#16a34a', borderRadius: 3, fontSize: 11, fontFamily: 'monospace' }}>{ch.newVal}</span>
</div>
))}
</div>
) : (
<div style={{ fontSize: 12, color: '#9ca3af', fontStyle: 'italic' }}>
Document updated
{entry.added > 0 && <span> · +{entry.added} row(s)</span>}
{entry.removed > 0 && <span> · -{entry.removed} row(s)</span>}
{entry.rowChanged > 0 && <span> · ~{entry.rowChanged} row(s) modified</span>}
</div>
)}
</div>
</div>
))}
{/* Creation entry at bottom */}
{docCreation && docOwner && (
<div style={{ position: 'relative', paddingLeft: 12 }}>
<div style={dotStyle('#22c55e')} />
<div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 8, padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div style={{ width: 26, height: 26, borderRadius: '50%', background: '#22c55e', color: 'white', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, fontWeight: 700, flexShrink: 0 }}>
{userInitials(docOwner)}
</div>
<span style={{ fontSize: 12, fontWeight: 600, color: '#374151' }}>{userLabel(docOwner)}</span>
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 'auto' }}>{timeAgo(docCreation)}</span>
</div>
<div style={{ fontSize: 12, color: '#15803d', fontWeight: 600 }}>✓ {t.activity.created}</div>
</div>
</div>
)}
{entries.length === 0 && !docCreation && (
<div style={{ textAlign: 'center', color: '#bbb', fontSize: 13, padding: '12px 0', fontStyle: 'italic' }}>No activity recorded</div>
)}
</div>
</div>
)}
{/* Show all / less */}
{!loading && hidden > 0 && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
<button
onClick={() => setShowAll(s => !s)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#3498db', fontSize: 12, fontFamily: 'Segoe UI', padding: '4px 8px' }}>
{showAll ? '▲ Show less' : `▼ Show all (${entries.length})`}
</button>
</div>
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,239 @@
import { NAV_ITEMS, settingsNavForRole } from '../constants'
import { Page } from '../types'
import logoImg from '../assets/logo.png'
interface AppSidebarProps {
collapsed: boolean
onToggleCollapsed: () => void
activePage: string
onNavigate: (page: Page) => void
hasDashboard: boolean
hasReports: boolean
hasDelegations: boolean
role: string
hideSettings?: boolean
rtl: boolean
navLabels: Record<string, string>
settingsLabel: string
}
export default function AppSidebar({
collapsed,
onToggleCollapsed,
activePage,
onNavigate,
hasDashboard,
hasReports,
hasDelegations,
role,
hideSettings,
rtl,
navLabels,
settingsLabel,
}: AppSidebarProps) {
const visibleSettingsNav = hideSettings ? [] : settingsNavForRole(role)
return (
<div
style={{
width: collapsed ? 60 : 230,
background: 'linear-gradient(180deg, #0f172a 0%, #1e1b4b 60%, #0b0f19 100%)',
color: 'white',
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
position: 'sticky',
top: 0,
height: '100vh',
transition: 'width 0.22s cubic-bezier(0.4, 0, 0.2, 1)',
overflow: 'hidden',
boxShadow: '2px 0 12px rgba(0, 0, 0, 0.15)',
borderRight: '1px solid rgba(255, 255, 255, 0.06)',
zIndex: 40,
}}
>
{/* Logo + toggle */}
<div
style={{
padding: collapsed ? '16px 0' : '16px 16px 14px',
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'space-between',
gap: 8,
flexShrink: 0,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, overflow: 'hidden' }}>
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.25)',
padding: 3,
boxSizing: 'border-box',
overflow: 'hidden',
}}
>
<img
src={logoImg}
alt="Logo"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
onError={e => {
(e.currentTarget as HTMLElement).style.display = 'none'
}}
/>
</div>
{!collapsed && (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: 14, fontWeight: 800, whiteSpace: 'nowrap', color: '#ffffff', letterSpacing: '-0.2px' }}>
{rtl ? 'صدرة للأعمال' : 'Sidrah'}
</span>
</div>
)}
</div>
<button
type="button"
onClick={onToggleCollapsed}
title={collapsed ? (rtl ? 'توسيع' : 'Expand') : (rtl ? 'طي' : 'Collapse')}
style={{
background: 'rgba(255, 255, 255, 0.06)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: 6,
color: 'rgba(255, 255, 255, 0.75)',
cursor: 'pointer',
fontSize: 11,
padding: '4px 7px',
flexShrink: 0,
lineHeight: 1,
transition: 'all 0.15s ease',
}}
onMouseEnter={e => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)'
e.currentTarget.style.color = '#ffffff'
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.06)'
e.currentTarget.style.color = 'rgba(255, 255, 255, 0.75)'
}}
>
{collapsed ? '▶' : '✕'}
</button>
</div>
{/* Navigation list */}
<nav style={{ padding: '12px 8px', flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
{NAV_ITEMS.filter(({ id }) => {
const isBusinessOwner = role === 'Business Owner' || role === 'Owner'
if (isBusinessOwner) return id === 'dashboard' && hasDashboard
return (id !== 'dashboard' || hasDashboard) && (id !== 'reports' || hasReports) && (id !== 'delegations' || hasDelegations)
}).map(({ id, icon }) => {
const label = navLabels[id] || id
const active = id === activePage
return (
<div
key={id}
onClick={() => onNavigate(id as Page)}
title={collapsed ? label : undefined}
className="sidebar-nav-item"
style={{
padding: collapsed ? '8px 0' : '8px 12px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
gap: 10,
background: active ? 'rgba(99, 102, 241, 0.28)' : 'transparent',
border: active ? '1px solid rgba(129, 140, 248, 0.35)' : '1px solid transparent',
borderRadius: 8,
fontSize: collapsed ? 15 : 13,
color: active ? '#ffffff' : 'rgba(255, 255, 255, 0.78)',
fontWeight: active ? 600 : 400,
transition: 'all 0.15s ease',
whiteSpace: 'nowrap',
marginBottom: 2,
position: 'relative',
}}
onMouseEnter={e => {
if (!active) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)'
e.currentTarget.style.color = '#ffffff'
}
}}
onMouseLeave={e => {
if (!active) {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = 'rgba(255, 255, 255, 0.78)'
}
}}
>
<span style={{ flexShrink: 0, fontSize: collapsed ? 15 : 13 }}>{icon}</span>
{!collapsed && <span>{label}</span>}
</div>
)
})}
{visibleSettingsNav.length > 0 && (
<>
{!collapsed && (
<div style={{ padding: '12px 12px 4px', fontSize: 10, color: 'rgba(255, 255, 255, 0.45)', fontWeight: 700, letterSpacing: '0.8px', textTransform: 'uppercase', marginTop: 8 }}>
{settingsLabel}
</div>
)}
{collapsed && <div style={{ margin: '10px auto', width: 26, borderTop: '1px solid rgba(255, 255, 255, 0.12)' }} />}
{visibleSettingsNav.map(({ id, icon }) => {
const label = navLabels[id] || id
const active = id === activePage
return (
<div
key={id}
onClick={() => onNavigate(id as Page)}
title={collapsed ? label : undefined}
className="sidebar-nav-item"
style={{
padding: collapsed ? '8px 0' : '8px 12px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
gap: 10,
background: active ? 'rgba(99, 102, 241, 0.28)' : 'transparent',
border: active ? '1px solid rgba(129, 140, 248, 0.35)' : '1px solid transparent',
borderRadius: 8,
fontSize: collapsed ? 15 : 13,
color: active ? '#ffffff' : 'rgba(255, 255, 255, 0.78)',
fontWeight: active ? 600 : 400,
transition: 'all 0.15s ease',
whiteSpace: 'nowrap',
marginBottom: 2,
}}
onMouseEnter={e => {
if (!active) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)'
e.currentTarget.style.color = '#ffffff'
}
}}
onMouseLeave={e => {
if (!active) {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = 'rgba(255, 255, 255, 0.78)'
}
}}
>
<span style={{ flexShrink: 0, fontSize: collapsed ? 15 : 13 }}>{icon}</span>
{!collapsed && <span>{label}</span>}
</div>
)
})}
</>
)}
</nav>
</div>
)
}

View File

@ -0,0 +1,191 @@
import { useState, useEffect, useCallback } from 'react'
import { getComments, postComment, deleteResource } from '../api'
import { useT } from '../i18n'
interface Comment {
name: string
owner: string
creation: string
content: string
comment_by: string
comment_email: string
}
interface Props {
doctype: string
docname: string
currentUserName: string
}
function timeAgo(dateStr: string): string {
const diff = (Date.now() - new Date(dateStr).getTime()) / 1000
if (diff < 60) return `${Math.floor(diff)}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 86400 * 7) return `${Math.floor(diff / 86400)}d ago`
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
function avatarInitials(name: string): string {
if (!name) return 'U'
const parts = name.replace(/@.*/, '').replace(/[._-]/g, ' ').split(' ').filter(Boolean)
return parts.slice(0, 2).map(p => p[0].toUpperCase()).join('')
}
export default function CommentSection({ doctype, docname, currentUserName }: Props) {
const t = useT()
const [comments, setComments] = useState<Comment[]>([])
const [loading, setLoading] = useState(false)
const [text, setText] = useState('')
const [posting, setPosting] = useState(false)
const [collapsed, setCollapsed] = useState(false)
const [showAll, setShowAll] = useState(false)
const [postError, setPostError] = useState('')
const INITIAL_SHOW = 5
const load = useCallback(async () => {
setLoading(true)
const data = await getComments(doctype, docname)
setComments(data)
setLoading(false)
}, [doctype, docname])
useEffect(() => { load() }, [load])
const submit = async () => {
const trimmed = text.trim()
if (!trimmed || posting) return
setPosting(true)
setPostError('')
try {
await postComment(doctype, docname, trimmed)
setText('')
await load()
} catch {
setPostError('Failed to post comment. Please try again.')
}
setPosting(false)
}
const remove = async (name: string) => {
if (!window.confirm('Delete this comment?')) return
try {
await deleteResource('Comment', name)
setComments(cs => cs.filter(c => c.name !== name))
} catch {
alert('Failed to delete comment.')
}
}
const visible = showAll ? comments : comments.slice(-INITIAL_SHOW)
const hidden = Math.max(0, comments.length - INITIAL_SHOW)
return (
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'hidden' }}>
{/* Header */}
<div
onClick={() => setCollapsed(c => !c)}
style={{ padding: '12px 20px', borderBottom: collapsed ? 'none' : '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', userSelect: 'none', background: 'white' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, color: '#1a2332' }}>
💬 {t.comments.title}
{comments.length > 0 && (
<span style={{ background: '#e8f4fd', color: '#2980b9', fontSize: 11, fontWeight: 700, borderRadius: 10, padding: '2px 8px' }}>
{comments.length}
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
onClick={e => { e.stopPropagation(); load() }}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#aaa', fontSize: 16, padding: '2px 4px', lineHeight: 1 }}
title="Refresh">
↻
</button>
<span style={{ color: '#ccc', fontSize: 11 }}>{collapsed ? '▼' : '▲'}</span>
</div>
</div>
{!collapsed && (
<div style={{ padding: '16px 20px' }}>
{/* Show older comments */}
{!showAll && hidden > 0 && (
<button
onClick={() => setShowAll(true)}
style={{ width: '100%', padding: '8px', marginBottom: 12, border: 'none', background: '#f8f9fa', borderRadius: 6, cursor: 'pointer', fontSize: 12, color: '#3498db', fontFamily: 'Segoe UI' }}>
Show {hidden} older comment{hidden !== 1 ? 's' : ''}
</button>
)}
{loading && (
<div style={{ textAlign: 'center', color: '#aaa', fontSize: 13, padding: '16px 0' }}>{t.loading}</div>
)}
{!loading && comments.length === 0 && (
<div style={{ textAlign: 'center', color: '#bbb', fontSize: 13, padding: '12px 0', fontStyle: 'italic' }}>{t.comments.noComments}</div>
)}
{/* Comment list */}
{!loading && visible.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 16 }}>
{visible.map(c => {
const displayName = c.comment_by || c.comment_email || c.owner || 'Unknown'
const isOwn = c.owner === currentUserName || c.comment_by === currentUserName
return (
<div key={c.name}
style={{ display: 'flex', gap: 10, padding: '12px 14px', background: '#f9fafb', borderRadius: 8, border: '1px solid #e8e8e8' }}>
<div style={{ width: 34, height: 34, borderRadius: '50%', background: '#3498db', color: 'white', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, flexShrink: 0 }}>
{avatarInitials(displayName)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: '#1a2332' }}>{displayName}</span>
<span style={{ fontSize: 11, color: '#aaa' }}>{timeAgo(c.creation)}</span>
</div>
{isOwn && (
<button
onClick={() => remove(c.name)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#e74c3c', fontSize: 16, padding: '0 2px', lineHeight: 1 }}
title="Delete comment">
×
</button>
)}
</div>
<div
style={{ fontSize: 13, color: '#374151', lineHeight: 1.55, wordBreak: 'break-word', whiteSpace: 'pre-wrap' }}
dangerouslySetInnerHTML={{ __html: c.content || '' }}
/>
</div>
</div>
)
})}
</div>
)}
{/* Compose box */}
<div style={{ borderTop: comments.length > 0 ? '1px solid #f0f0f0' : 'none', paddingTop: comments.length > 0 ? 14 : 0 }}>
<textarea
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) submit() }}
placeholder={t.comments.placeholder}
rows={3}
style={{ width: '100%', padding: '10px 12px', border: '1px solid #ddd', borderRadius: 8, fontSize: 13, fontFamily: 'Segoe UI', resize: 'vertical', boxSizing: 'border-box', outline: 'none', color: '#1a2332' }}
/>
{postError && (
<div style={{ fontSize: 12, color: '#e74c3c', marginTop: 4 }}>{postError}</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 8 }}>
<button
onClick={submit}
disabled={!text.trim() || posting}
style={{ padding: '8px 20px', background: '#3498db', color: 'white', border: 'none', borderRadius: 6, cursor: !text.trim() || posting ? 'not-allowed' : 'pointer', fontSize: 13, fontWeight: 600, fontFamily: 'Segoe UI', opacity: !text.trim() || posting ? 0.6 : 1 }}>
{posting ? t.saving : t.comments.post}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,51 @@
import React from 'react'
interface DateInputProps {
value: string // ISO yyyy-mm-dd
onChange: (v: string) => void
disabled?: boolean
style?: React.CSSProperties
min?: string
max?: string
}
function toDisplay(iso: string): string {
if (!iso) return ''
const [y, m, d] = iso.split('-')
if (!y || !m || !d) return ''
return `${d}/${m}/${y}`
}
// Native <input type="date"> renders its text in whatever format the browser's
// locale dictates (often MM/DD/YYYY), which HTML/CSS cannot override. This
// wrapper shows a DD/MM/YYYY read-only label while a transparent native date
// input sits on top to handle clicks/typing and the calendar popup.
export default function DateInput({ value, onChange, disabled, style, min, max }: DateInputProps) {
return (
<div style={{ position: 'relative', width: '100%' }}>
<input
type="text"
readOnly
tabIndex={-1}
value={toDisplay(value)}
placeholder="DD/MM/YYYY"
style={{ ...style, pointerEvents: 'none', paddingRight: 30 }}
/>
<svg
width="15" height="15" viewBox="0 0 24 24" fill="none" stroke={disabled ? '#bbb' : '#666'} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none' }}
>
<rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
</svg>
<input
type="date"
value={value || ''}
disabled={disabled}
min={min}
max={max}
onChange={e => onChange(e.target.value)}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0, cursor: disabled ? 'default' : 'pointer', border: 'none', padding: 0, margin: 0 }}
/>
</div>
)
}

View File

@ -0,0 +1,285 @@
import { useState, useEffect } from 'react'
import { useT } from '../i18n'
interface DeleteConfirmModalProps {
isOpen: boolean
doctype: string
docname: string
docTitle?: string
isAdmin: boolean
isDeleting?: boolean
onClose: () => void
onConfirmSoftDelete: (reason: string) => void
onConfirmPermanentDelete: (reason: string) => void
}
export default function DeleteConfirmModal({
isOpen,
doctype,
docname,
docTitle,
isAdmin,
isDeleting,
onClose,
onConfirmSoftDelete,
onConfirmPermanentDelete,
}: DeleteConfirmModalProps) {
const t = useT()
const [reason, setReason] = useState('')
useEffect(() => {
if (isOpen) {
setReason('')
}
}, [isOpen])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen && !isDeleting) {
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, isDeleting, onClose])
if (!isOpen) return null
const displayDoctype = doctype.replace(/^SB\s+/, '')
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '12px',
backgroundColor: 'rgba(15, 23, 42, 0.6)',
backdropFilter: 'blur(3px)',
animation: 'fadeIn 0.15s ease',
}}
onClick={e => {
if (e.target === e.currentTarget && !isDeleting) onClose()
}}
>
<div
className="delete-modal-card"
style={{
background: 'white',
borderRadius: 12,
boxShadow: '0 20px 35px -10px rgba(0, 0, 0, 0.22)',
maxWidth: 390,
width: '100%',
padding: '16px 18px',
boxSizing: 'border-box',
position: 'relative',
animation: 'scaleIn 0.15s ease',
border: '1px solid rgba(226, 232, 240, 0.9)',
}}
>
{/* Header with Icon */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: '#fee2e2',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
color: '#ef4444',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18m-2 0v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6m3 0V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 700, color: '#1e293b' }}>
{t.deleteModal.title}
</h3>
<p style={{ margin: '2px 0 0', fontSize: 11, color: '#64748b', lineHeight: 1.3 }}>
{t.deleteModal.subtitle}
</p>
</div>
<button
type="button"
onClick={onClose}
disabled={isDeleting}
style={{
background: 'transparent',
border: 'none',
color: '#94a3b8',
cursor: 'pointer',
padding: '2px 4px',
borderRadius: 4,
fontSize: 14,
lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
✕
</button>
</div>
{/* Item Info Badge */}
<div
style={{
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 6,
padding: '6px 10px',
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
<span style={{ fontSize: 10, fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.4px', flexShrink: 0 }}>
{displayDoctype}:
</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#1e293b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{docTitle ? `${docTitle} (${docname})` : docname}
</span>
</div>
{/* Reason Input */}
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: '#475569', marginBottom: 4 }}>
{t.deleteModal.reasonLabel}
</label>
<input
type="text"
value={reason}
onChange={e => setReason(e.target.value)}
placeholder={t.deleteModal.reasonPlaceholder}
disabled={isDeleting}
style={{
width: '100%',
height: 30,
padding: '4px 8px',
border: '1px solid #cbd5e1',
borderRadius: 6,
fontSize: 12,
boxSizing: 'border-box',
outline: 'none',
}}
onFocus={e => {
e.target.style.borderColor = '#6366f1'
e.target.style.boxShadow = '0 0 0 2px rgba(99, 102, 241, 0.15)'
}}
onBlur={e => {
e.target.style.borderColor = '#cbd5e1'
e.target.style.boxShadow = 'none'
}}
/>
</div>
{/* Action Buttons */}
<div
style={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 8,
flexWrap: 'wrap',
}}
>
{/* Cancel button */}
<button
type="button"
onClick={onClose}
disabled={isDeleting}
style={{
padding: '5px 12px',
borderRadius: 6,
border: '1px solid #cbd5e1',
background: '#ffffff',
color: '#475569',
fontSize: 12,
fontWeight: 600,
cursor: isDeleting ? 'not-allowed' : 'pointer',
transition: 'background 0.15s',
}}
>
{t.deleteModal.cancelBtn}
</button>
{/* Delete (Soft Delete) button */}
<button
type="button"
onClick={() => onConfirmSoftDelete(reason)}
disabled={isDeleting}
title={t.deleteModal.softDeleteDesc}
style={{
padding: '5px 12px',
borderRadius: 6,
border: '1px solid #fca5a5',
background: '#fff1f2',
color: '#b91c1c',
fontSize: 12,
fontWeight: 600,
cursor: isDeleting ? 'not-allowed' : 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
transition: 'all 0.15s',
}}
onMouseEnter={e => {
if (!isDeleting) e.currentTarget.style.background = '#fee2e2'
}}
onMouseLeave={e => {
if (!isDeleting) e.currentTarget.style.background = '#fff1f2'
}}
>
<span>🗑️</span>
{isDeleting ? t.deleteModal.deleting : t.deleteModal.softDeleteBtn}
</button>
{/* Permanent Delete button (Only for Admin) */}
{isAdmin && (
<button
type="button"
onClick={() => onConfirmPermanentDelete(reason)}
disabled={isDeleting}
title={t.deleteModal.permanentDeleteDesc}
style={{
padding: '5px 12px',
borderRadius: 6,
border: 'none',
background: '#ef4444',
color: '#ffffff',
fontSize: 12,
fontWeight: 600,
cursor: isDeleting ? 'not-allowed' : 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
boxShadow: '0 1px 3px rgba(239, 68, 68, 0.25)',
transition: 'all 0.15s',
}}
onMouseEnter={e => {
if (!isDeleting) e.currentTarget.style.background = '#dc2626'
}}
onMouseLeave={e => {
if (!isDeleting) e.currentTarget.style.background = '#ef4444'
}}
>
<span>💥</span>
{isDeleting ? t.deleteModal.deleting : t.deleteModal.permanentDeleteBtn}
</button>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,207 @@
import { useState, useEffect } from 'react'
import { ExportColumn, exportToCSV, exportToXLSX } from '../utils/export'
interface ExportModalProps {
title: string
rows: any[] // all rows (current filter)
selectedRows?: any[] // rows the user checked
pageRows?: any[] // rows on the current page
columns: ExportColumn[]
defaultColumnKeys: string[]
filenameBase: string
onClose: () => void
}
const ACCENT = '#16a34a'
export default function ExportModal({ title, rows, selectedRows = [], pageRows = [], columns, defaultColumnKeys, filenameBase, onClose }: ExportModalProps) {
type Scope = 'selected' | 'page' | 'all'
const [scope, setScope] = useState<Scope>(selectedRows.length > 0 ? 'selected' : 'all')
const [format, setFormat] = useState<'csv' | 'xlsx'>('csv')
const [selected, setSelected] = useState<Set<string>>(new Set(defaultColumnKeys))
const [colSearch, setColSearch] = useState('')
useEffect(() => {
if (selectedRows && selectedRows.length > 0) {
setScope('selected')
}
}, [selectedRows])
const scopeRows = scope === 'selected' ? selectedRows : scope === 'page' ? pageRows : rows
const selectedColumns = columns.filter(c => selected.has(c.key))
const filteredCols = colSearch ? columns.filter(c => c.label.toLowerCase().includes(colSearch.toLowerCase())) : columns
const toggleColumn = (key: string) => {
setSelected(prev => {
const next = new Set(prev)
next.has(key) ? next.delete(key) : next.add(key)
return next
})
}
const runExport = async () => {
if (selectedColumns.length === 0 || scopeRows.length === 0) return
if (format === 'csv') exportToCSV(selectedColumns, scopeRows, filenameBase)
else await exportToXLSX(selectedColumns, scopeRows, filenameBase)
onClose()
}
const sectionLbl: React.CSSProperties = { fontSize: 11, fontWeight: 700, color: '#9ca3af', letterSpacing: 0.4, textTransform: 'uppercase', marginBottom: 10 }
const linkBtn: React.CSSProperties = { background: 'none', border: 'none', color: '#2563eb', fontSize: 12, fontWeight: 600, cursor: 'pointer', padding: 0, fontFamily: 'inherit' }
const scopeOptions: { id: Scope; label: string; sub: string; count: number; disabled?: boolean }[] = [
{ id: 'selected', label: 'Selected rows', sub: `${selectedRows.length} row${selectedRows.length !== 1 ? 's' : ''} selected`, count: selectedRows.length, disabled: selectedRows.length === 0 },
{ id: 'page', label: 'Current page', sub: `${pageRows.length} row${pageRows.length !== 1 ? 's' : ''} on this page`, count: pageRows.length },
{ id: 'all', label: 'All records (current filters)', sub: `${rows.length} total matching records`, count: rows.length },
]
return (
<div
onClick={onClose}
style={{ position: 'fixed', inset: 0, background: 'rgba(15,23,42,0.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000 }}
>
<div
onClick={e => e.stopPropagation()}
style={{ width: 580, maxWidth: '92vw', maxHeight: '90vh', background: 'white', borderRadius: 12, boxShadow: '0 20px 60px rgba(0,0,0,0.3)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
{/* Header */}
<div style={{ background: ACCENT, color: 'white', padding: '16px 22px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 16, fontWeight: 700 }}>
<span style={{ fontSize: 18 }}>📤</span> Export {title}
<span style={{ fontSize: 12, fontWeight: 400, opacity: 0.8 }}>{columns.length} fields available · {selected.size} selected</span>
</div>
<button onClick={onClose} style={{ background: 'rgba(255,255,255,0.15)', border: 'none', color: 'white', width: 28, height: 28, borderRadius: 8, cursor: 'pointer', fontSize: 15, lineHeight: 1 }}>✕</button>
</div>
{/* Body */}
<div style={{ padding: '20px 22px', overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* What to export */}
<div>
<div style={sectionLbl}>What to export</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{scopeOptions.map(opt => (
<label
key={opt.id}
onClick={() => !opt.disabled && setScope(opt.id)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 16px', borderRadius: 10, cursor: opt.disabled ? 'not-allowed' : 'pointer',
border: `2px solid ${scope === opt.id ? ACCENT : '#e5e7eb'}`,
background: scope === opt.id ? '#f0fdf4' : opt.disabled ? '#fafafa' : 'white',
opacity: opt.disabled ? 0.5 : 1,
transition: 'border-color 0.15s, background 0.15s',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 16, height: 16, borderRadius: '50%', border: `2px solid ${scope === opt.id ? ACCENT : '#d1d5db'}`,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
{scope === opt.id && <div style={{ width: 7, height: 7, borderRadius: '50%', background: ACCENT }} />}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#111827' }}>{opt.label}</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 1 }}>{opt.sub}</div>
</div>
</div>
<span style={{ background: scope === opt.id ? ACCENT : '#e5e7eb', color: scope === opt.id ? 'white' : '#374151', fontSize: 12, fontWeight: 700, padding: '2px 10px', borderRadius: 9999 }}>
{opt.count}
</span>
</label>
))}
</div>
</div>
{/* File Format */}
<div>
<div style={sectionLbl}>File Format</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
{[
{ id: 'csv' as const, label: 'CSV', desc: 'Universal, works everywhere', icon: '📄' },
{ id: 'xlsx' as const, label: 'Excel (.xlsx)', desc: 'Native Excel workbook', icon: '📊' },
].map(f => (
<div
key={f.id}
onClick={() => setFormat(f.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '12px 14px', borderRadius: 10, cursor: 'pointer',
border: `2px solid ${format === f.id ? ACCENT : '#e5e7eb'}`,
background: format === f.id ? '#f0fdf4' : 'white',
}}
>
<div style={{ width: 16, height: 16, borderRadius: '50%', border: `2px solid ${format === f.id ? ACCENT : '#d1d5db'}`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{format === f.id && <div style={{ width: 7, height: 7, borderRadius: '50%', background: ACCENT }} />}
</div>
<span style={{ fontSize: 20 }}>{f.icon}</span>
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#1a2332' }}>{f.label}</div>
<div style={{ fontSize: 11, color: '#888' }}>{f.desc}</div>
</div>
</div>
))}
</div>
</div>
{/* Columns to Export */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div style={{ ...sectionLbl, marginBottom: 0 }}>Columns to Export</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button onClick={() => setSelected(new Set(columns.map(c => c.key)))} style={linkBtn}>All</button>
<span style={{ color: '#d1d5db' }}>|</span>
<button onClick={() => setSelected(new Set(defaultColumnKeys))} style={linkBtn}>Default</button>
<span style={{ color: '#d1d5db' }}>|</span>
<button onClick={() => setSelected(new Set())} style={{ ...linkBtn, color: '#ef4444' }}>None</button>
</div>
</div>
<div style={{ marginBottom: 8 }}>
<input
type="text"
placeholder="Search fields..."
value={colSearch}
onChange={e => setColSearch(e.target.value)}
style={{ width: '100%', padding: '7px 10px', borderRadius: 8, border: '1px solid #e5e7eb', fontSize: 12, boxSizing: 'border-box', background: '#f9fafb' }}
/>
</div>
<div style={{ border: '1px solid #e5e7eb', borderRadius: 8, padding: 12, maxHeight: 170, overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: '10px 12px' }}>
{filteredCols.map(c => (
<label key={c.key} style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 13, color: '#374151', cursor: 'pointer', padding: '3px 5px', borderRadius: 5, background: selected.has(c.key) ? '#f0fdf4' : 'transparent' }}>
<input type="checkbox" checked={selected.has(c.key)} onChange={() => toggleColumn(c.key)} style={{ accentColor: ACCENT, cursor: 'pointer' }} />
{c.label}
</label>
))}
</div>
</div>
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 6 }}>{selected.size} of {columns.length} fields selected</div>
</div>
</div>
{/* Footer */}
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 12, color: '#888' }}>
Exporting {scopeRows.length} {scope === 'selected' ? 'selected' : scope === 'page' ? 'page' : ''} row{scopeRows.length !== 1 ? 's' : ''}
</span>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={onClose} style={{ padding: '9px 18px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', color: '#374151', fontWeight: 600, fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>
Cancel
</button>
<button
onClick={runExport}
disabled={selectedColumns.length === 0 || scopeRows.length === 0}
style={{
padding: '9px 20px', borderRadius: 8, border: 'none', background: ACCENT, color: 'white', fontWeight: 700, fontSize: 13,
cursor: (selectedColumns.length === 0 || scopeRows.length === 0) ? 'not-allowed' : 'pointer',
opacity: (selectedColumns.length === 0 || scopeRows.length === 0) ? 0.5 : 1, fontFamily: 'inherit',
display: 'flex', alignItems: 'center', gap: 6,
}}
>
⬇ Export
</button>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,55 @@
import { useState } from 'react'
interface SearchFilter {
type: 'search'
label: string
value: string
onChange: (v: string) => void
placeholder?: string
}
interface SelectFilter {
type: 'select'
label: string
value: string
onChange: (v: string) => void
options: { value: string; label: string }[]
}
type FilterDef = SearchFilter | SelectFilter
const inp: React.CSSProperties = { padding: '8px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 13, background: 'white', width: '100%', fontFamily: 'inherit', outline: 'none', color: '#374151', boxSizing: 'border-box' }
const sel: React.CSSProperties = { ...inp, cursor: 'pointer' }
export default function FilterBar({ filters, defaultOpen = false }: { filters: FilterDef[]; defaultOpen?: boolean }) {
const [open, setOpen] = useState(defaultOpen)
return (
<div style={{ background: 'white', borderRadius: 12, overflow: 'hidden', boxShadow: '0 1px 4px rgba(0,0,0,0.08)', marginBottom: 16 }}>
<div
onClick={() => setOpen(o => !o)}
style={{ background: 'linear-gradient(90deg, #3b82f6 0%, #2563eb 100%)', padding: '12px 18px', display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round">{open ? <polyline points="18,15 12,9 6,15"/> : <polyline points="6,9 12,15 18,9"/>}</svg>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
<span style={{ color: 'white', fontWeight: 700, fontSize: 14 }}>Filters</span>
</div>
{open && (
<div style={{ padding: '18px 20px' }}>
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.min(filters.length, 4)}, 1fr)`, gap: 14 }}>
{filters.map((f, i) => (
<div key={i}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', marginBottom: 5, textTransform: 'uppercase', letterSpacing: 0.5 }}>{f.label}</div>
{f.type === 'search' ? (
<input style={inp} placeholder={f.placeholder} value={f.value} onChange={e => f.onChange(e.target.value)} />
) : (
<select style={sel} value={f.value} onChange={e => f.onChange(e.target.value)}>
{f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,44 @@
import { useState, ReactNode } from 'react'
export const fLbl: React.CSSProperties = {
fontSize: 11, color: '#475569', fontWeight: 600, marginBottom: 4, display: 'block', textTransform: 'uppercase', letterSpacing: 0.4,
}
export const fInp: React.CSSProperties = {
width: '100%', padding: '6px 10px', border: '1px solid #cbd5e1', borderRadius: 6,
fontSize: 12, background: 'white', color: '#1e293b', boxSizing: 'border-box',
fontFamily: 'Segoe UI, Arial, sans-serif', height: 32, outline: 'none',
}
export default function FilterSection({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false)
return (
<div style={{ borderRadius: 10, overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.06)', flexShrink: 0, border: '1px solid #e2e8f0', marginBottom: 14 }}>
<div
onClick={() => setOpen(v => !v)}
style={{
background: 'linear-gradient(90deg, #3b82f6 0%, #2563eb 100%)',
color: 'white',
padding: '10px 18px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 10,
userSelect: 'none',
fontWeight: 600,
fontSize: 13,
}}
>
<span style={{ fontSize: 12, opacity: 0.9 }}>{open ? '▲' : '▼'}</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
<span>Filters</span>
</div>
{open && (
<div style={{ background: '#f8fafc', padding: '14px 18px', borderTop: '1px solid #e2e8f0' }}>
{children}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,336 @@
import { ReactNode, useState, useEffect, useRef } from 'react'
import { User, Page } from '../types'
import AppSidebar from './AppSidebar'
import TopBar from './TopBar'
import { useT } from '../i18n'
interface FormPageLayoutProps {
title: string
activePage: string
user: User
formStatus: string
buttonText?: string
buttonColor?: string
viewMode?: boolean
statusBadge?: ReactNode
onEdit?: () => void
onCancel?: () => void
hideSettings?: boolean
rightPanel?: ReactNode
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
children: ReactNode
}
export default function FormPageLayout({
title, activePage, user, formStatus, buttonText,
viewMode, statusBadge, onEdit, onCancel, hideSettings, rightPanel,
darkMode, notifCount, onToggleDark, onCountChange, onViewProfile,
onSubmit, onBack, onNavigate, onLogout, children
}: FormPageLayoutProps) {
const t = useT()
const role = user.role
const hasDashboard = role === 'Admin' || role === 'Owner' || role === 'Auditor' || role === 'Manager' || role === 'CEO' || role === 'Chairman' || role === 'XAuditor'
const hasReports = role === 'Admin' || role === 'Owner' || role === 'Auditor' || role === 'Manager' || role === 'CEO' || role === 'Chairman' || role === 'XAuditor'
const hasDelegations = role === 'Admin' || role === 'Auditor' || role === 'Manager' || role === 'CEO' || role === 'Chairman'
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('sb_sidebar') === '1')
const prevViewModeRef = useRef<boolean | undefined>(undefined)
const [showEditToast, setShowEditToast] = useState(false)
useEffect(() => {
// Only show "Edit mode enabled" if transitioning from viewMode (true) to edit mode (false).
// Never show for newly opened forms (which start with viewMode = false).
if (prevViewModeRef.current === true && viewMode === false) {
setShowEditToast(true)
const timer = setTimeout(() => {
setShowEditToast(false)
}, 4000)
return () => clearTimeout(timer)
} else {
setShowEditToast(false)
}
prevViewModeRef.current = viewMode
}, [viewMode])
const toggleCollapsed = () => setCollapsed(c => { const n = !c; localStorage.setItem('sb_sidebar', n ? '1' : '0'); return n })
const handleCancel = onCancel || onBack
return (
<div style={{ display: 'flex', minHeight: '100vh', fontFamily: 'Segoe UI', background: darkMode ? '#0f172a' : '#f8fafc' }}>
{/* Sidebar */}
<AppSidebar
collapsed={collapsed}
onToggleCollapsed={toggleCollapsed}
activePage={activePage}
onNavigate={onNavigate}
hasDashboard={hasDashboard}
hasReports={hasReports}
hasDelegations={hasDelegations}
role={role}
hideSettings={hideSettings}
rtl={t.rtl}
navLabels={t.nav as Record<string, string>}
settingsLabel={t.nav.settings}
/>
{/* Main Column */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{/* ROW 1: Clean TopBar Strip (matches List Pages) */}
<div style={{
background: darkMode ? '#1e293b' : '#ffffff',
padding: '6px 20px',
borderBottom: `1px solid ${darkMode ? '#2d3748' : '#e9eaf0'}`,
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
gap: 6,
flexShrink: 0,
boxShadow: darkMode ? 'none' : '0 1px 4px rgba(0,0,0,0.03)'
}}>
<TopBar
notifCount={notifCount}
darkMode={darkMode}
onCountChange={onCountChange}
onToggleDark={onToggleDark}
onNavigate={onNavigate}
onViewProfile={onViewProfile}
onLogout={onLogout}
userName={user.full_name || user.name}
userRole={user.role}
/>
</div>
{/* ROW 2: Dedicated Form Header Bar (Separated from TopBar) */}
<div style={{
background: darkMode ? '#1e293b' : '#ffffff',
padding: '12px 24px',
borderBottom: `1px solid ${darkMode ? '#2d3748' : '#e2e8f0'}`,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
position: 'relative'
}}>
{/* Left: Back Button + Form Title + Inline Status Badge */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button
onClick={onBack}
title={t.back}
style={{
width: 32,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
border: `1px solid ${darkMode ? '#475569' : '#e2e8f0'}`,
background: darkMode ? '#334155' : '#ffffff',
cursor: 'pointer',
color: '#2563eb',
flexShrink: 0,
transition: 'all 0.15s ease'
}}
onMouseEnter={e => { e.currentTarget.style.background = darkMode ? '#475569' : '#eff6ff'; e.currentTarget.style.borderColor = '#93c5fd' }}
onMouseLeave={e => { e.currentTarget.style.background = darkMode ? '#334155' : '#ffffff'; e.currentTarget.style.borderColor = darkMode ? '#475569' : '#e2e8f0' }}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<div style={{ fontSize: 17, fontWeight: 700, color: darkMode ? '#f8fafc' : '#0f172a' }}>{title}</div>
{statusBadge && (
typeof statusBadge === 'string' ? (
<span style={{
padding: '3px 10px',
borderRadius: 16,
fontSize: 12,
fontWeight: 600,
background: '#fef3c7',
color: '#92400e',
border: '1px solid #fde68a'
}}>
{statusBadge}
</span>
) : statusBadge
)}
</div>
</div>
{/* Right: Actions (View Mode -> Edit button; Edit Mode -> Cancel + Green Save Changes) */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{viewMode && onEdit && (
<button
type="button"
onClick={onEdit}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '7px 16px',
borderRadius: 6,
border: 'none',
background: '#2563eb',
color: '#ffffff',
cursor: 'pointer',
fontSize: 13,
fontWeight: 600,
boxShadow: '0 1px 3px rgba(37,99,235,0.25)',
transition: 'all 0.15s ease'
}}
onMouseEnter={e => (e.currentTarget.style.background = '#1d4ed8')}
onMouseLeave={e => (e.currentTarget.style.background = '#2563eb')}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
{t.edit}
</button>
)}
{!viewMode && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
onClick={handleCancel}
style={{
padding: '7px 16px',
borderRadius: 6,
border: `1px solid ${darkMode ? '#475569' : '#cbd5e1'}`,
background: darkMode ? '#334155' : '#ffffff',
color: darkMode ? '#e2e8f0' : '#475569',
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
onMouseEnter={e => { e.currentTarget.style.background = darkMode ? '#475569' : '#f1f5f9' }}
onMouseLeave={e => { e.currentTarget.style.background = darkMode ? '#334155' : '#ffffff' }}
>
{t.cancel}
</button>
<button
type="button"
onClick={onSubmit}
disabled={formStatus === 'loading'}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '7px 18px',
borderRadius: 6,
border: 'none',
background: '#16a34a',
color: '#ffffff',
fontSize: 13,
fontWeight: 600,
cursor: formStatus === 'loading' ? 'not-allowed' : 'pointer',
boxShadow: '0 1px 3px rgba(22, 163, 74, 0.3)',
opacity: formStatus === 'loading' ? 0.7 : 1,
transition: 'all 0.15s ease'
}}
onMouseEnter={e => { if (formStatus !== 'loading') e.currentTarget.style.background = '#15803d' }}
onMouseLeave={e => { if (formStatus !== 'loading') e.currentTarget.style.background = '#16a34a' }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<span>{formStatus === 'loading' ? t.saving : (buttonText || 'Save Changes')}</span>
</button>
</div>
)}
</div>
{/* Floating Edit Mode Toast Chip (Matching Image 3) */}
{!viewMode && showEditToast && (
<div style={{
position: 'absolute',
top: -34,
right: 24,
background: '#0284c7',
color: '#ffffff',
padding: '4px 12px',
borderRadius: 6,
fontSize: 12,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: 8,
boxShadow: '0 2px 8px rgba(2, 132, 199, 0.3)',
zIndex: 10
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
Edit mode enabled
</span>
<button
type="button"
onClick={() => setShowEditToast(false)}
title="Dismiss"
style={{
background: 'transparent',
border: 'none',
color: 'white',
cursor: 'pointer',
fontSize: 14,
padding: 0,
lineHeight: 1,
opacity: 0.85
}}
>
✕
</button>
</div>
)}
</div>
{/* Content area */}
<div style={{
flex: 1,
overflowY: 'auto',
padding: '20px 24px',
display: 'grid',
gridTemplateColumns: rightPanel ? '1fr 240px' : '1fr',
gap: 16,
alignItems: 'start',
}}>
<div style={{ minWidth: 0 }}>
{children}
{formStatus === 'success' && (
<div style={{ padding: '10px 14px', borderRadius: 8, background: '#f0fdf4', border: '1px solid #86efac', color: '#166534', fontSize: 13, marginTop: 14 }}>
{t.savedOk}
</div>
)}
{formStatus !== 'idle' && formStatus !== 'loading' && formStatus !== 'success' && (
<div style={{ padding: '10px 14px', borderRadius: 8, background: '#fef2f2', border: '1px solid #fca5a5', color: '#991b1b', fontSize: 13, marginTop: 14 }}>
{formStatus}
</div>
)}
</div>
{rightPanel && (
<div style={{ position: 'sticky', top: 0, alignSelf: 'start', width: 240 }}>
{rightPanel}
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,17 @@
import { ReactNode } from 'react'
interface FormSectionProps {
title?: string
children: ReactNode
}
export default function FormSection({ title, children }: FormSectionProps) {
return (
<div style={{ background: 'white', borderRadius: 10, padding: '18px 20px', marginBottom: 14, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
{title && <div style={{ fontSize: 15, fontWeight: 700, marginBottom: 16, color: '#1e1b4b' }}>{title}</div>}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr)', gap: 13 }}>
{children}
</div>
</div>
)
}

View File

@ -0,0 +1,393 @@
import { useState, useRef, useEffect } from 'react'
import { post } from '../api'
interface LinkSelectProps {
value: string
onChange: (val: string) => void
items: Array<{ name: string; label: string }>
placeholder?: string
disabled?: boolean
onCreateNew?: () => void
createNewText?: string
doctypeName?: string
userRole?: string | null
onQuickCreateSuccess?: (name: string) => void
extraFields?: Record<string, any>
}
function getNameField(doctype?: string): { key: string; label: string } {
if (!doctype) return { key: 'name1', label: 'Name' }
if (doctype === 'Department') return { key: 'department_name', label: 'Department Name' }
if (doctype === 'Branch') return { key: 'branch', label: 'Branch Name' }
if (doctype === 'SB Organization' || doctype === 'Organization') return { key: 'organization_name', label: 'Organization Name' }
if (doctype === 'SB Group Company' || doctype === 'Group Company') return { key: 'company_name', label: 'Company Name' }
return { key: 'name1', label: `${doctype.replace(/^SB\s+/, '')} Name` }
}
export default function LinkSelect({
value,
onChange,
items,
placeholder = 'Select...',
disabled = false,
onCreateNew,
createNewText,
doctypeName,
userRole,
onQuickCreateSuccess,
extraFields = {},
}: LinkSelectProps) {
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [hovered, setHovered] = useState('')
const [showQuickModal, setShowQuickModal] = useState(false)
const [quickInput, setQuickInput] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [errorMsg, setErrorMsg] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const visibleItems = (items || []).filter(i => {
if (!i) return false
const itemAny = i as any
if (itemAny.custom_is_deleted === 1 || itemAny.custom_is_deleted === '1' || itemAny.custom_is_deleted === true) return false
if (itemAny.is_deleted === 1 || itemAny.is_deleted === '1' || itemAny.is_deleted === true) return false
if (itemAny.status === 'Deleted' || itemAny.custom_status === 'Deleted') return false
if (itemAny.enabled === 0 || itemAny.enabled === false) return false
if (itemAny.docstatus === 2) return false
return true
})
const selectedLabel = value
? (visibleItems.find(i => i.name === value)?.label ?? (items || []).find(i => i.name === value)?.label ?? value)
: ''
const filteredItems = visibleItems.filter(item => {
if (!searchQuery.trim()) return true
const q = searchQuery.toLowerCase().trim()
return (item.label || '').toLowerCase().includes(q) || (item.name || '').toLowerCase().includes(q)
})
const isEmployee = userRole === 'Employee' || userRole === 'SB Employee'
const allowCreate = !isEmployee && (!!onCreateNew || !!doctypeName || !!createNewText)
const defaultCreateLabel = doctypeName ? `Create New ${doctypeName.replace(/^SB\s+/, '')}` : 'Create New'
const displayCreateText = createNewText || defaultCreateLabel
const handleCreateSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!quickInput.trim() || !doctypeName) return
setIsSaving(true)
setErrorMsg('')
try {
const fieldInfo = getNameField(doctypeName)
const payload: Record<string, any> = {
[fieldInfo.key]: quickInput.trim(),
...extraFields,
}
if (['Incident Types', 'Idea Types', 'Order Types', 'Process Types'].includes(doctypeName)) {
payload.status = 'Active'
}
const res = await post(doctypeName, payload)
const createdDoc = res?.data || res
const createdName = createdDoc?.name || createdDoc?.[fieldInfo.key] || quickInput.trim()
onChange(createdName)
if (onQuickCreateSuccess) {
onQuickCreateSuccess(createdName)
}
setShowQuickModal(false)
setQuickInput('')
} catch (err: any) {
console.error('Quick create error:', err)
setErrorMsg(err?.message || 'Failed to create item. Please try again.')
} finally {
setIsSaving(false)
}
}
return (
<>
<div ref={containerRef} className={`custom-select-container ${disabled ? 'disabled' : ''} ${open ? 'open' : ''}`} style={{ position: 'relative', width: '100%' }}>
<div
className={`custom-select-trigger ${disabled ? 'disabled' : ''} ${open ? 'open' : ''} ${!value && !searchQuery ? 'is-placeholder' : ''}`}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingRight: 8 }}
onClick={() => {
if (!disabled) {
if (!open) {
setOpen(true)
setSearchQuery('')
}
setTimeout(() => inputRef.current?.focus(), 0)
}
}}
>
<input
ref={inputRef}
type="text"
disabled={disabled}
placeholder={placeholder}
value={open ? searchQuery : selectedLabel}
onChange={e => {
if (!open) setOpen(true)
setSearchQuery(e.target.value)
}}
onFocus={() => {
if (!disabled && !open) {
setOpen(true)
setSearchQuery('')
}
}}
style={{
border: 'none',
outline: 'none',
background: 'transparent',
width: '100%',
fontSize: 'inherit',
fontFamily: 'inherit',
color: 'inherit',
cursor: disabled ? 'not-allowed' : 'text',
padding: 0,
}}
/>
{value && !disabled ? (
<span
title="Clear selection"
style={{
cursor: 'pointer',
color: '#94a3b8',
fontSize: 14,
fontWeight: 600,
padding: '0 4px',
lineHeight: 1,
userSelect: 'none',
transition: 'color 0.15s ease',
flexShrink: 0,
}}
onClick={e => {
e.stopPropagation()
onChange('')
setSearchQuery('')
setOpen(false)
}}
onMouseEnter={e => (e.currentTarget.style.color = '#475569')}
onMouseLeave={e => (e.currentTarget.style.color = '#94a3b8')}
>
✕
</span>
) : (
<span className="trigger-arrow" style={{ pointerEvents: 'none', flexShrink: 0 }}>▼</span>
)}
</div>
{open && (
<div className="custom-select-dropdown">
<div style={{ maxHeight: 260, overflowY: 'auto' }}>
{filteredItems.length === 0 && !allowCreate && (
<div className="custom-select-empty">No matching records found</div>
)}
{filteredItems.map(item => (
<div
key={item.name}
className={`custom-select-option ${value === item.name ? 'selected' : ''} ${hovered === item.name ? 'hovered' : ''}`}
onClick={() => {
onChange(item.name)
setSearchQuery('')
setOpen(false)
}}
onMouseEnter={() => setHovered(item.name)}
onMouseLeave={() => setHovered('')}
>
{item.label}
</div>
))}
{allowCreate && (
<div
className={`custom-select-option create-new-option ${hovered === '__create_new' ? 'hovered' : ''}`}
style={{
borderTop: '1px solid #e2e8f0',
color: '#16a34a',
fontWeight: 700,
fontSize: 13,
padding: '10px 14px',
display: 'flex',
alignItems: 'center',
gap: 6,
cursor: 'pointer',
background: hovered === '__create_new' ? '#f0fdf4' : '#ffffff',
transition: 'all 0.15s ease',
}}
onClick={() => {
setOpen(false)
if (onCreateNew) {
onCreateNew()
} else {
setQuickInput('')
setErrorMsg('')
setShowQuickModal(true)
}
}}
onMouseEnter={() => setHovered('__create_new')}
onMouseLeave={() => setHovered('')}
>
<span style={{ fontSize: 16, fontWeight: 800, color: '#16a34a', lineHeight: 1 }}>+</span>
<span>{displayCreateText}</span>
</div>
)}
</div>
</div>
)}
</div>
{/* Quick Create Modal matching Image 2 design */}
{showQuickModal && doctypeName && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 10000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(15, 23, 42, 0.55)',
backdropFilter: 'blur(3px)',
padding: 16,
}}
onClick={e => {
if (e.target === e.currentTarget && !isSaving) setShowQuickModal(false)
}}
>
<div
style={{
background: '#ffffff',
borderRadius: 12,
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.2)',
maxWidth: 440,
width: '100%',
overflow: 'hidden',
animation: 'scaleIn 0.15s ease',
}}
>
{/* Modal Header */}
<div
style={{
padding: '16px 20px',
borderBottom: '1px solid #f1f5f9',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 16, fontWeight: 700, color: '#1e293b' }}>
<span style={{ color: '#2563eb', fontSize: 18, fontWeight: 800 }}>+</span>
<span>{`Create New ${doctypeName.replace(/^SB\s+/, '')}`}</span>
</div>
<button
type="button"
onClick={() => !isSaving && setShowQuickModal(false)}
style={{
background: 'none',
border: 'none',
color: '#94a3b8',
fontSize: 18,
cursor: 'pointer',
padding: '2px 6px',
borderRadius: 4,
}}
>
✕
</button>
</div>
{/* Modal Body Form */}
<form onSubmit={handleCreateSubmit} style={{ padding: '20px' }}>
{errorMsg && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', color: '#dc2626', padding: '8px 12px', borderRadius: 6, fontSize: 12, marginBottom: 14 }}>
{errorMsg}
</div>
)}
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 12, fontWeight: 700, color: '#334155', marginBottom: 6 }}>
{getNameField(doctypeName).label} <span style={{ color: '#ef4444' }}>*</span>
</label>
<input
type="text"
value={quickInput}
onChange={e => setQuickInput(e.target.value)}
placeholder={`Enter ${getNameField(doctypeName).label.toLowerCase()}`}
autoFocus
disabled={isSaving}
style={{
width: '100%',
padding: '9px 12px',
borderRadius: 8,
border: '1px solid #cbd5e1',
fontSize: 13,
boxSizing: 'border-box',
outline: 'none',
}}
/>
</div>
{/* Modal Footer Buttons */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 22 }}>
<button
type="button"
onClick={() => setShowQuickModal(false)}
disabled={isSaving}
style={{
padding: '8px 16px',
borderRadius: 8,
border: '1px solid #cbd5e1',
background: '#f8fafc',
color: '#475569',
fontWeight: 600,
fontSize: 13,
cursor: isSaving ? 'not-allowed' : 'pointer',
}}
>
Cancel
</button>
<button
type="submit"
disabled={isSaving || !quickInput.trim()}
style={{
padding: '8px 18px',
borderRadius: 8,
border: 'none',
background: '#2563eb',
color: '#ffffff',
fontWeight: 700,
fontSize: 13,
cursor: isSaving || !quickInput.trim() ? 'not-allowed' : 'pointer',
opacity: isSaving || !quickInput.trim() ? 0.6 : 1,
display: 'flex',
alignItems: 'center',
gap: 6,
boxShadow: '0 2px 4px rgba(37, 99, 235, 0.25)',
}}
>
<span style={{ fontSize: 15, fontWeight: 800 }}>+</span>
<span>{isSaving ? 'Creating...' : 'Create'}</span>
</button>
</div>
</form>
</div>
</div>
)}
</>
)
}

View File

@ -0,0 +1,138 @@
import { useEffect, useRef, useState } from 'react'
import { getNotifications, markAllNotificationsRead } from '../api'
interface Notif {
name: string
subject: string
type?: string
document_type?: string
document_name?: string
read: number
creation: string
}
interface Props {
count: number
userName: string
onCountChange: (n: number) => void
}
function timeAgo(dateStr: string): string {
const diff = (Date.now() - new Date(dateStr).getTime()) / 1000
if (diff < 60) return `${Math.floor(diff)}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
export default function NotificationBell({ count, onCountChange }: Props) {
const [open, setOpen] = useState(false)
const [notifs, setNotifs] = useState<Notif[]>([])
const [loading, setLoading] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const load = async () => {
setLoading(true)
const data = await getNotifications(30)
setNotifs(data)
setLoading(false)
}
const handleOpen = () => {
const next = !open
setOpen(next)
if (next) load()
}
const handleMarkAll = async () => {
await markAllNotificationsRead()
setNotifs(prev => prev.map(n => ({ ...n, read: 1 })))
onCountChange(0)
}
const unread = notifs.filter(n => !n.read).length
return (
<div ref={ref} style={{ position: 'relative' }}>
{/* Bell icon */}
<div onClick={handleOpen}
style={{ position: 'relative', width: 34, height: 34, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: open ? '#e8f0fe' : 'transparent', cursor: 'pointer', transition: 'background 0.15s' }}>
<span style={{ fontSize: 17, lineHeight: 1, userSelect: 'none' }}>🔔</span>
{count > 0 && (
<span style={{ position: 'absolute', top: 1, right: 1, background: '#ef4444', color: 'white', fontSize: 9, fontWeight: 700, borderRadius: 999, minWidth: 15, height: 15, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 3px', lineHeight: 1 }}>
{count > 99 ? '99+' : count}
</span>
)}
</div>
{/* Dropdown */}
{open && (
<div style={{ position: 'absolute', right: 0, top: 'calc(100% + 6px)', width: 340, background: 'white', borderRadius: 10, boxShadow: '0 8px 32px rgba(0,0,0,0.18)', zIndex: 10000, border: '1px solid #e5e7eb', overflow: 'hidden' }}>
{/* Header */}
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'white' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, color: '#1a2332' }}>
Notifications
{unread > 0 && (
<span style={{ background: '#ef4444', color: 'white', fontSize: 10, fontWeight: 700, borderRadius: 999, padding: '2px 7px' }}>
{unread} new
</span>
)}
</div>
{unread > 0 && (
<button onClick={handleMarkAll}
style={{ fontSize: 12, color: '#3498db', background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'Segoe UI', padding: 0 }}>
Mark all read
</button>
)}
</div>
{/* List */}
<div style={{ maxHeight: 380, overflowY: 'auto' }}>
{loading && (
<div style={{ padding: '20px', textAlign: 'center', color: '#aaa', fontSize: 13 }}>Loading…</div>
)}
{!loading && notifs.length === 0 && (
<div style={{ padding: '32px 20px', textAlign: 'center', color: '#aaa', fontSize: 13 }}>No notifications</div>
)}
{!loading && notifs.length > 0 && (
<>
{notifs.some(n => !n.read) && (
<div style={{ padding: '5px 16px', fontSize: 10, fontWeight: 700, color: '#999', letterSpacing: 0.8, textTransform: 'uppercase', background: '#f9fafb', borderBottom: '1px solid #f0f0f0' }}>
NEW
</div>
)}
{notifs.map(n => (
<div key={n.name}
style={{ padding: '11px 16px', borderBottom: '1px solid #f5f5f5', display: 'flex', gap: 10, background: n.read ? 'white' : '#eff6ff', cursor: 'default' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: n.read ? 400 : 600, color: '#1a2332', marginBottom: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{n.subject || `${n.document_type}: ${n.document_name}`}
</div>
{(n.document_type || n.type) && (
<div style={{ fontSize: 11, color: '#888', marginBottom: 2 }}>
{n.document_type}{n.document_name ? ` · ${n.document_name}` : ''}
</div>
)}
<div style={{ fontSize: 11, color: '#aaa' }}>{timeAgo(n.creation)}</div>
</div>
{!n.read && (
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#3498db', flexShrink: 0, marginTop: 5 }} />
)}
</div>
))}
</>
)}
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,415 @@
import { useState, useRef, useEffect } from 'react'
import { Organization } from '../types'
import { post } from '../api'
interface OrgSelectProps {
value: string
onChange: (val: string) => void
organizations: Organization[]
placeholder?: string
disabled?: boolean
small?: boolean
userRole?: string | null
onCreateNew?: () => void
onQuickCreateSuccess?: (name: string) => void
}
export default function OrgSelect({
value,
onChange,
organizations,
placeholder = 'Select organization...',
disabled = false,
small = false,
userRole,
onCreateNew,
onQuickCreateSuccess,
}: OrgSelectProps) {
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [showQuickModal, setShowQuickModal] = useState(false)
const [orgNameInput, setOrgNameInput] = useState('')
const [acronymInput, setAcronymInput] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [errorMsg, setErrorMsg] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const selectedLabel = value
? (organizations.find(o => o.organization_name === value || o.name === value)?.organization_name ?? value)
: ''
const selectableOrgs = organizations.filter(o => {
if (!o) return false
const oAny = o as any
if (oAny.custom_is_deleted === 1 || oAny.custom_is_deleted === '1' || oAny.custom_is_deleted === true) return false
if (oAny.is_deleted === 1 || oAny.is_deleted === '1' || oAny.is_deleted === true) return false
if (oAny.status === 'Deleted' || oAny.custom_status === 'Deleted') return false
return !!o.is_active || o.organization_name === value || o.name === value
})
const filteredOrgs = selectableOrgs.filter(o => {
if (!searchQuery.trim()) return true
const q = searchQuery.toLowerCase().trim()
return (o.organization_name || '').toLowerCase().includes(q) || (o.name || '').toLowerCase().includes(q) || (o.acronym || '').toLowerCase().includes(q)
})
const isEmployee = userRole === 'Employee' || userRole === 'SB Employee'
const allowCreate = !isEmployee
const handleCreateSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!orgNameInput.trim()) return
setIsSaving(true)
setErrorMsg('')
try {
const payload = {
organization_name: orgNameInput.trim(),
acronym: acronymInput.trim(),
is_active: 1,
}
const res = await post('SB Organization', payload)
const createdDoc = res?.data || res
const createdName = createdDoc?.organization_name || orgNameInput.trim()
onChange(createdName)
if (onQuickCreateSuccess) {
onQuickCreateSuccess(createdName)
}
setShowQuickModal(false)
setOrgNameInput('')
setAcronymInput('')
} catch (err: any) {
console.error('Quick create org error:', err)
setErrorMsg(err?.message || 'Failed to create organization. Please try again.')
} finally {
setIsSaving(false)
}
}
return (
<>
<div ref={containerRef} className={`custom-select-container ${disabled ? 'disabled' : ''} ${open ? 'open' : ''}`} style={{ position: 'relative', width: '100%' }}>
{/* Trigger */}
<div
className={`custom-select-trigger ${small ? 'small' : ''} ${disabled ? 'disabled' : ''} ${open ? 'open' : ''} ${!value && !searchQuery ? 'is-placeholder' : ''}`}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingRight: 8 }}
onClick={() => {
if (!disabled) {
if (!open) {
setOpen(true)
setSearchQuery('')
}
setTimeout(() => inputRef.current?.focus(), 0)
}
}}
>
<input
ref={inputRef}
type="text"
disabled={disabled}
placeholder={placeholder}
value={open ? searchQuery : selectedLabel}
onChange={e => {
if (!open) setOpen(true)
setSearchQuery(e.target.value)
}}
onFocus={() => {
if (!disabled && !open) {
setOpen(true)
setSearchQuery('')
}
}}
style={{
border: 'none',
outline: 'none',
background: 'transparent',
width: '100%',
fontSize: 'inherit',
fontFamily: 'inherit',
color: 'inherit',
cursor: disabled ? 'not-allowed' : 'text',
padding: 0,
}}
/>
{value && !disabled ? (
<span
title="Clear selection"
style={{
cursor: 'pointer',
color: '#94a3b8',
fontSize: 14,
fontWeight: 600,
padding: '0 4px',
lineHeight: 1,
userSelect: 'none',
transition: 'color 0.15s ease',
flexShrink: 0,
}}
onClick={e => {
e.stopPropagation()
onChange('')
setSearchQuery('')
setOpen(false)
}}
onMouseEnter={e => (e.currentTarget.style.color = '#475569')}
onMouseLeave={e => (e.currentTarget.style.color = '#94a3b8')}
>
✕
</span>
) : (
<span className="trigger-arrow" style={{ pointerEvents: 'none', flexShrink: 0 }}>▼</span>
)}
</div>
{/* Dropdown */}
{open && (
<div className="custom-select-dropdown">
<div style={{ maxHeight: 260, overflowY: 'auto' }}>
{filteredOrgs.length === 0 && !allowCreate && (
<div className="custom-select-empty">No matching organizations</div>
)}
{filteredOrgs.map(o => (
<OrgOption
key={o.name}
label={o.organization_name}
selected={value === o.organization_name || value === o.name}
active={!!o.is_active}
isPlaceholder={false}
small={small}
onClick={() => {
onChange(o.organization_name)
setSearchQuery('')
setOpen(false)
}}
/>
))}
{allowCreate && (
<div
style={{
borderTop: '1px solid #e2e8f0',
color: '#16a34a',
fontWeight: 700,
fontSize: small ? 12 : 13,
padding: '10px 14px',
display: 'flex',
alignItems: 'center',
gap: 6,
cursor: 'pointer',
background: '#ffffff',
transition: 'all 0.15s ease',
}}
onClick={() => {
setOpen(false)
if (onCreateNew) {
onCreateNew()
} else {
setOrgNameInput('')
setAcronymInput('')
setErrorMsg('')
setShowQuickModal(true)
}
}}
onMouseEnter={e => { e.currentTarget.style.background = '#f0fdf4' }}
onMouseLeave={e => { e.currentTarget.style.background = '#ffffff' }}
>
<span style={{ fontSize: 16, fontWeight: 800, color: '#16a34a', lineHeight: 1 }}>+</span>
<span>Create New Organization</span>
</div>
)}
</div>
</div>
)}
</div>
{/* Quick Create Organization Modal */}
{showQuickModal && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 10000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(15, 23, 42, 0.55)',
backdropFilter: 'blur(3px)',
padding: 16,
}}
onClick={e => {
if (e.target === e.currentTarget && !isSaving) setShowQuickModal(false)
}}
>
<div
style={{
background: '#ffffff',
borderRadius: 12,
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.2)',
maxWidth: 440,
width: '100%',
overflow: 'hidden',
animation: 'scaleIn 0.15s ease',
}}
>
{/* Modal Header */}
<div
style={{
padding: '16px 20px',
borderBottom: '1px solid #f1f5f9',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 16, fontWeight: 700, color: '#1e293b' }}>
<span style={{ color: '#2563eb', fontSize: 18, fontWeight: 800 }}>+</span>
<span>Create New Organization</span>
</div>
<button
type="button"
onClick={() => !isSaving && setShowQuickModal(false)}
style={{
background: 'none',
border: 'none',
color: '#94a3b8',
fontSize: 18,
cursor: 'pointer',
padding: '2px 6px',
borderRadius: 4,
}}
>
✕
</button>
</div>
{/* Modal Body Form */}
<form onSubmit={handleCreateSubmit} style={{ padding: '20px' }}>
{errorMsg && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', color: '#dc2626', padding: '8px 12px', borderRadius: 6, fontSize: 12, marginBottom: 14 }}>
{errorMsg}
</div>
)}
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 12, fontWeight: 700, color: '#334155', marginBottom: 6 }}>
Organization Name <span style={{ color: '#ef4444' }}>*</span>
</label>
<input
type="text"
value={orgNameInput}
onChange={e => setOrgNameInput(e.target.value)}
placeholder="Enter organization name"
autoFocus
disabled={isSaving}
style={{
width: '100%',
padding: '9px 12px',
borderRadius: 8,
border: '1px solid #cbd5e1',
fontSize: 13,
boxSizing: 'border-box',
outline: 'none',
}}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 12, fontWeight: 700, color: '#334155', marginBottom: 6 }}>
Acronym
</label>
<input
type="text"
value={acronymInput}
onChange={e => setAcronymInput(e.target.value)}
placeholder="e.g. KAU"
disabled={isSaving}
style={{
width: '100%',
padding: '9px 12px',
borderRadius: 8,
border: '1px solid #cbd5e1',
fontSize: 13,
boxSizing: 'border-box',
outline: 'none',
}}
/>
</div>
{/* Modal Footer Buttons */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 22 }}>
<button
type="button"
onClick={() => setShowQuickModal(false)}
disabled={isSaving}
style={{
padding: '8px 16px',
borderRadius: 8,
border: '1px solid #cbd5e1',
background: '#f8fafc',
color: '#475569',
fontWeight: 600,
fontSize: 13,
cursor: isSaving ? 'not-allowed' : 'pointer',
}}
>
Cancel
</button>
<button
type="submit"
disabled={isSaving || !orgNameInput.trim()}
style={{
padding: '8px 18px',
borderRadius: 8,
border: 'none',
background: '#2563eb',
color: '#ffffff',
fontWeight: 700,
fontSize: 13,
cursor: isSaving || !orgNameInput.trim() ? 'not-allowed' : 'pointer',
opacity: isSaving || !orgNameInput.trim() ? 0.6 : 1,
display: 'flex',
alignItems: 'center',
gap: 6,
boxShadow: '0 2px 4px rgba(37, 99, 235, 0.25)',
}}
>
<span style={{ fontSize: 15, fontWeight: 800 }}>+</span>
<span>{isSaving ? 'Creating...' : 'Create'}</span>
</button>
</div>
</form>
</div>
</div>
)}
</>
)
}
function OrgOption({ label, selected, active, isPlaceholder, small, onClick }: {
label: string; selected: boolean; active: boolean; isPlaceholder: boolean; small?: boolean; onClick: () => void
}) {
const [hovered, setHovered] = useState(false)
return (
<div
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={`custom-select-option ${selected ? 'selected' : ''} ${hovered ? 'hovered' : ''} ${isPlaceholder ? 'placeholder-option' : ''} ${!active ? 'inactive-option' : ''} ${small ? 'small' : ''}`}
>
{label}
</div>
)
}

View File

@ -0,0 +1,222 @@
import React, { useState } from 'react'
export const PAGE_SIZE = 20
export interface PaginationProps {
page: number
pageSize?: number
total: number
onPageChange: (page: number) => void
itemLabel?: string
}
function getPageItems(currentPage: number, totalPages: number): (number | 'ellipsis')[] {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1)
}
const items: (number | 'ellipsis')[] = []
if (currentPage <= 4) {
for (let i = 1; i <= Math.min(4, totalPages); i++) items.push(i)
if (totalPages > 4) {
items.push('ellipsis')
items.push(totalPages)
}
} else if (currentPage >= totalPages - 3) {
items.push(1)
items.push('ellipsis')
for (let i = totalPages - 3; i <= totalPages; i++) items.push(i)
} else {
items.push(1)
items.push('ellipsis')
items.push(currentPage - 1)
items.push(currentPage)
items.push(currentPage + 1)
items.push('ellipsis')
items.push(totalPages)
}
return items
}
export default function Pagination({
page,
pageSize = PAGE_SIZE,
total,
onPageChange,
itemLabel = 'results',
}: PaginationProps) {
const [inputPage, setInputPage] = useState('')
if (total === 0) return null
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
const handleGo = (e?: React.FormEvent) => {
if (e) e.preventDefault()
const p = parseInt(inputPage.trim(), 10)
if (!isNaN(p) && p >= 1 && p <= totalPages) {
onPageChange(p)
setInputPage('')
}
}
const pageItems = getPageItems(page, totalPages)
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '14px 8px',
fontSize: 13,
color: '#475569',
flexWrap: 'wrap',
gap: 12,
userSelect: 'none',
}}
>
<div style={{ color: '#475569', fontWeight: 500 }}>
Showing {start} to {end} of {total} {itemLabel}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
{/* Previous Button */}
<button
type="button"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
style={{
padding: '5px 12px',
borderRadius: 6,
border: '1px solid #e2e8f0',
background: '#ffffff',
color: page <= 1 ? '#94a3b8' : '#475569',
fontSize: 13,
fontWeight: 500,
cursor: page <= 1 ? 'not-allowed' : 'pointer',
transition: 'all 0.15s ease',
fontFamily: 'inherit',
}}
>
Previous
</button>
{/* Page Numbers */}
{pageItems.map((item, idx) => {
if (item === 'ellipsis') {
return (
<span
key={`ellipsis-${idx}`}
style={{
padding: '0 4px',
color: '#94a3b8',
fontSize: 13,
fontWeight: 600,
}}
>
...
</span>
)
}
const isActive = item === page
return (
<button
key={item}
type="button"
onClick={() => onPageChange(item)}
style={{
minWidth: 32,
height: 32,
padding: '0 6px',
borderRadius: 6,
border: isActive ? '1px solid #2563eb' : '1px solid #e2e8f0',
background: isActive ? '#2563eb' : '#ffffff',
color: isActive ? '#ffffff' : '#334155',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: isActive ? 'default' : 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.15s ease',
fontFamily: 'inherit',
}}
>
{item}
</button>
)
})}
{/* Next Button */}
<button
type="button"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
style={{
padding: '5px 12px',
borderRadius: 6,
border: '1px solid #e2e8f0',
background: '#ffffff',
color: page >= totalPages ? '#94a3b8' : '#475569',
fontSize: 13,
fontWeight: 500,
cursor: page >= totalPages ? 'not-allowed' : 'pointer',
transition: 'all 0.15s ease',
fontFamily: 'inherit',
}}
>
Next
</button>
{/* Go to page form */}
<form
onSubmit={handleGo}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
marginLeft: 8,
}}
>
<span style={{ fontSize: 13, color: '#64748b' }}>Go to page:</span>
<input
type="number"
min={1}
max={totalPages}
value={inputPage}
onChange={e => setInputPage(e.target.value)}
style={{
width: 52,
height: 30,
padding: '0 6px',
border: '1px solid #cbd5e1',
borderRadius: 6,
fontSize: 13,
textAlign: 'center',
outline: 'none',
fontFamily: 'inherit',
}}
/>
<button
type="submit"
style={{
background: 'none',
border: 'none',
color: '#2563eb',
fontWeight: 600,
fontSize: 13,
cursor: 'pointer',
padding: '4px 6px',
fontFamily: 'inherit',
}}
>
Go
</button>
</form>
</div>
</div>
)
}

View File

@ -0,0 +1,488 @@
import React, { useState, useEffect, useCallback } from 'react'
import {
fetchProcess,
applyAction,
getUserRoles,
ProcessDoc,
ProgressRow,
} from '../utils/processAPI'
// ---------------------------------------------------------------------------
// Shared style tokens
// ---------------------------------------------------------------------------
const S = {
card: {
background: 'white',
borderRadius: 10,
boxShadow: '0 1px 4px rgba(0,0,0,0.10)',
padding: 24,
maxWidth: 900,
} as React.CSSProperties,
title: { fontSize: 18, fontWeight: 700, color: '#1a2332', marginBottom: 4 } as React.CSSProperties,
subtitle: { fontSize: 13, color: '#6b7280', marginBottom: 20 } as React.CSSProperties,
label: { fontSize: 12, fontWeight: 600, color: '#6b7280', marginBottom: 4, display: 'block' } as React.CSSProperties,
input: {
width: '100%', padding: '8px 10px', border: '1px solid #d1d5db',
borderRadius: 6, fontSize: 13, fontFamily: 'Segoe UI', boxSizing: 'border-box',
} as React.CSSProperties,
readVal: { fontSize: 13, color: '#1a2332', padding: '4px 0' } as React.CSSProperties,
btn: (color: string): React.CSSProperties => ({
padding: '9px 22px', background: color, color: 'white', border: 'none',
borderRadius: 6, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'Segoe UI',
}),
th: { padding: '10px 12px', textAlign: 'left' as const, color: '#6b7280', fontSize: 11, fontWeight: 700 },
td: { padding: '8px 12px', fontSize: 13 },
err: { color: '#e74c3c', fontSize: 13, marginTop: 10 } as React.CSSProperties,
ok: { color: '#27ae60', fontSize: 13, marginTop: 10, fontWeight: 600 } as React.CSSProperties,
stateBadge: (state: string): React.CSSProperties => ({
display: 'inline-block', padding: '3px 12px', borderRadius: 12, fontSize: 12, fontWeight: 700,
marginBottom: 20,
background:
state === 'Draft' ? '#e0f2fe' :
state === 'Pending Actor' ? '#fef3c7' :
state === 'Pending Comment' ? '#ede9fe' :
state === 'Review' ? '#dcfce7' :
state === 'In Study' ? '#f3f4f6' : '#f3f4f6',
color:
state === 'Draft' ? '#0369a1' :
state === 'Pending Actor' ? '#92400e' :
state === 'Pending Comment' ? '#5b21b6' :
state === 'Review' ? '#166534' :
'#374151',
}),
statusBadge: (v: string): React.CSSProperties => ({
display: 'inline-block', padding: '2px 10px', borderRadius: 12, fontSize: 11, fontWeight: 700,
background: v === 'Below Min' ? '#fef3c7' : v === 'Above Max' ? '#fee2e2' : v === 'At Risk' ? '#ede9fe' : '#dcfce7',
color: v === 'Below Min' ? '#92400e' : v === 'Above Max' ? '#991b1b' : v === 'At Risk' ? '#5b21b6' : '#166534',
}),
}
// ---------------------------------------------------------------------------
// Shared: indicator table (read-only)
// ---------------------------------------------------------------------------
function ReadOnlyTable({ rows, showComment }: { rows: ProgressRow[]; showComment?: boolean }) {
const cols = showComment
? ['Name', 'Target', 'Min', 'Max', 'Status', 'Comment']
: ['Name', 'Target', 'Min', 'Max', 'Status']
return (
<div style={{ overflowX: 'auto', marginBottom: 20 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f8f9fa' }}>
{cols.map(h => <th key={h} style={S.th}>{h}</th>)}
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr><td colSpan={cols.length} style={{ padding: 24, textAlign: 'center', color: '#aaa' }}>No indicators</td></tr>
)}
{rows.map((row, i) => (
<tr key={i} style={{ background: i % 2 === 0 ? 'white' : '#fafafa' }}>
<td style={{ ...S.td, fontWeight: 600 }}>{row.name1 || '—'}</td>
<td style={S.td}>{row.target || '—'}</td>
<td style={S.td}>{row.minimum || '—'}</td>
<td style={S.td}>{row.maximum || '—'}</td>
<td style={S.td}>
{row.status
? <span style={S.statusBadge(row.status)}>{row.status}</span>
: <span style={{ color: '#aaa' }}>—</span>}
</td>
{showComment && <td style={{ ...S.td, color: '#555', maxWidth: 220 }}>{row.cycle_comment || '—'}</td>}
</tr>
))}
</tbody>
</table>
</div>
)
}
// ---------------------------------------------------------------------------
// Step 1 — Auditor submits draft (state = "Draft")
// ---------------------------------------------------------------------------
export function Step1Auditor({ doc, onSuccess }: { doc: ProcessDoc; onSuccess: () => void }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async () => {
setBusy(true); setError('')
try {
await applyAction(doc.name, 'Submit for Review')
onSuccess()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Submit failed')
}
setBusy(false)
}
return (
<div style={S.card}>
<div style={S.title}>Step 1 — Review & Submit</div>
<span style={S.stateBadge('Draft')}>Draft</span>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 20, marginBottom: 20 }}>
<div><span style={S.label}>Title</span><span style={S.readVal}>{doc.title || '—'}</span></div>
<div><span style={S.label}>Number</span><span style={S.readVal}>{doc.number || '—'}</span></div>
<div><span style={S.label}>Organization</span><span style={S.readVal}>{String(doc.organization || '—')}</span></div>
</div>
<ReadOnlyTable rows={doc.progress_indicators || []} />
{error && <div style={S.err}>{error}</div>}
<button type="button" onClick={handleSubmit} disabled={busy}
style={{ ...S.btn('#2980b9'), opacity: busy ? 0.6 : 1 }}>
{busy ? 'Submitting…' : 'Submit for Review'}
</button>
</div>
)
}
// ---------------------------------------------------------------------------
// Step 2 — Employee updates status (state = "Pending Actor")
// Actor enters Actual Value; system auto-computes Normal / Below Min / Above Max
// ---------------------------------------------------------------------------
export function Step2Employee({ doc, onSuccess }: { doc: ProcessDoc; onSuccess: () => void }) {
const [rows, setRows] = useState<ProgressRow[]>(
(doc.progress_indicators || []).map(r => ({ ...r, actual_value: r.actual_value ?? '' }))
)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const updateActual = (i: number, val: string) =>
setRows(prev => prev.map((r, idx) => (idx === i ? { ...r, actual_value: val } : r)))
const updateComment = (i: number, c: string) =>
setRows(prev => prev.map((r, idx) => (idx === i ? { ...r, cycle_comment: c } : r)))
// Preview computed status before submit
function previewStatus(row: ProgressRow): string {
const v = parseFloat(String(row.actual_value))
if (isNaN(v)) return '—'
const mn = parseFloat(String(row.minimum))
const mx = parseFloat(String(row.maximum))
if (!isNaN(mn) && v < mn) return 'Below Min'
if (!isNaN(mx) && v > mx) return 'Above Max'
return 'Normal'
}
const handleSubmit = async () => {
setBusy(true); setError('')
try {
await applyAction(doc.name, 'Update Status', { progress_indicators: rows })
onSuccess()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Submit failed')
}
setBusy(false)
}
return (
<div style={S.card}>
<div style={S.title}>Step 2 — Enter Actual Values</div>
<span style={S.stateBadge('Pending Actor')}>Pending Actor</span>
<p style={{ fontSize: 13, color: '#6b7280', marginBottom: 16 }}>
Enter the actual value for each indicator. Status is computed automatically.
</p>
<div style={{ overflowX: 'auto', marginBottom: 20 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f8f9fa' }}>
{['Indicator', 'Target', 'Min', 'Max', 'Actual Value', 'Status', 'Comment'].map(h =>
<th key={h} style={S.th}>{h}</th>
)}
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr><td colSpan={7} style={{ padding: 24, textAlign: 'center', color: '#aaa' }}>No indicators defined</td></tr>
)}
{rows.map((row, i) => {
const ps = previewStatus(row)
const psColor = ps === 'Below Min' || ps === 'Above Max' ? '#dc2626' : ps === 'Normal' ? '#16a34a' : '#9ca3af'
return (
<tr key={i} style={{ background: i % 2 === 0 ? 'white' : '#fafafa' }}>
<td style={{ ...S.td, fontWeight: 600 }}>{row.name1 || '—'}</td>
<td style={S.td}>{row.target || '—'}</td>
<td style={S.td}>{row.minimum || '—'}</td>
<td style={S.td}>{row.maximum || '—'}</td>
<td style={S.td}>
<input
type="number"
style={{ ...S.input, width: 90 }}
value={String(row.actual_value ?? '')}
onChange={e => updateActual(i, e.target.value)}
placeholder="0"
/>
</td>
<td style={{ ...S.td, fontWeight: 700, color: psColor }}>{ps}</td>
<td style={S.td}>
<input
type="text"
style={{ ...S.input, width: 140 }}
value={row.cycle_comment || ''}
onChange={e => updateComment(i, e.target.value)}
placeholder="Optional note"
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{error && <div style={S.err}>{error}</div>}
<button type="button" onClick={handleSubmit} disabled={busy}
style={{ ...S.btn('#2980b9'), opacity: busy ? 0.6 : 1 }}>
{busy ? 'Submitting…' : 'Submit Status'}
</button>
</div>
)
}
// ---------------------------------------------------------------------------
// Step 3 — Owner / Auditor adds comment (state = "Pending Comment")
// ---------------------------------------------------------------------------
export function Step3Comment({ doc, onSuccess }: { doc: ProcessDoc; onSuccess: () => void }) {
const [rows, setRows] = useState<ProgressRow[]>(
(doc.progress_indicators || []).map(r => ({ ...r }))
)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const updateComment = (i: number, cycle_comment: string) =>
setRows(prev => prev.map((r, idx) => (idx === i ? { ...r, cycle_comment } : r)))
const handleSubmit = async () => {
setBusy(true); setError('')
try {
await applyAction(doc.name, 'Add Comment', { progress_indicators: rows })
onSuccess()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Submit failed')
}
setBusy(false)
}
return (
<div style={S.card}>
<div style={S.title}>Step 3 — Add Comments</div>
<span style={S.stateBadge('Pending Comment')}>Pending Comment</span>
<div style={{ overflowX: 'auto', marginBottom: 20 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f8f9fa' }}>
{['Name', 'Target', 'Min', 'Max', 'Status', 'Comment'].map(h => <th key={h} style={S.th}>{h}</th>)}
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr><td colSpan={6} style={{ padding: 24, textAlign: 'center', color: '#aaa' }}>No indicators</td></tr>
)}
{rows.map((row, i) => (
<tr key={i} style={{ background: i % 2 === 0 ? 'white' : '#fafafa' }}>
<td style={{ ...S.td, fontWeight: 600 }}>{row.name1}</td>
<td style={S.td}>{row.target || '—'}</td>
<td style={S.td}>{row.minimum || '—'}</td>
<td style={S.td}>{row.maximum || '—'}</td>
<td style={S.td}>
{row.status
? <span style={S.statusBadge(row.status)}>{row.status}</span>
: <span style={{ color: '#aaa' }}>—</span>}
</td>
<td style={S.td}>
<textarea
style={{ ...S.input, width: 200, height: 56, resize: 'vertical' as const }}
value={row.cycle_comment || ''}
onChange={e => updateComment(i, e.target.value)}
placeholder="Add comment…"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{error && <div style={S.err}>{error}</div>}
<button type="button" onClick={handleSubmit} disabled={busy}
style={{ ...S.btn('#2980b9'), opacity: busy ? 0.6 : 1 }}>
{busy ? 'Submitting…' : 'Add Comment'}
</button>
</div>
)
}
// ---------------------------------------------------------------------------
// Step 4 — Auditor reviews (state = "Review"): Start New Cycle OR Move to Study
// ---------------------------------------------------------------------------
export function Step4Review({ doc, onSuccess }: { doc: ProcessDoc; onSuccess: () => void }) {
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState('')
const [doneIncident, setDoneIncident] = useState(doc.study_incident || '')
const isCompleted = (doc as any).completion_pct === 100 || (doc as any).stage === 5 || doc.status === 'Completion' || doc.status === 'Completed' || doc.workflow_state === 'Completed' || doc.workflow_state === 'Closed'
const handleAction = async (action: string) => {
setBusy(action); setError('')
try {
const updated = await applyAction(doc.name, action)
if (updated.study_incident) setDoneIncident(updated.study_incident as string)
onSuccess()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Action failed')
}
setBusy(null)
}
return (
<div style={S.card}>
<div style={S.title}>Step 4 — Auditor Review</div>
<span style={S.stateBadge('Review')}>Review</span>
<ReadOnlyTable rows={doc.progress_indicators || []} showComment />
{doneIncident ? (
<div style={S.ok}>Study created: <strong>{doneIncident}</strong></div>
) : (
<>
{error && <div style={S.err}>{error}</div>}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{isCompleted ? (
<button type="button" onClick={() => handleAction('Close Process')} disabled={!!busy}
style={{ ...S.btn('#475569'), opacity: busy ? 0.6 : 1 }}>
{busy === 'Close Process' ? 'Processing…' : 'Close Process'}
</button>
) : (
<button type="button" onClick={() => handleAction('Start New Cycle')} disabled={!!busy}
style={{ ...S.btn('#2563eb'), opacity: busy ? 0.6 : 1 }}>
{busy === 'Start New Cycle' ? 'Processing…' : 'Start New Cycle'}
</button>
)}
<button type="button" onClick={() => handleAction('Move to Study')} disabled={!!busy}
style={{ ...S.btn('#8e44ad'), opacity: busy ? 0.6 : 1 }}>
{busy === 'Move to Study' ? 'Processing…' : 'Move to Study'}
</button>
</div>
<div style={{ marginTop: 10, fontSize: 12, color: '#94a3b8' }}>
{isCompleted ? 'Close Process — process is 100% completed.' : 'Start New Cycle — process returns to Actor for the next cycle update.'}
Move to Study — escalates for formal analysis.
</div>
</>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// ProcessWorkflow — root component
// ---------------------------------------------------------------------------
interface ProcessWorkflowProps {
processName: string
/** Simplified role from login: 'Manager' | 'Auditor' | 'Employee' */
userRole?: string
/** Raw Frappe role names (optional fallback) */
userRoles?: string[]
}
export default function ProcessWorkflow({ processName, userRole, userRoles }: ProcessWorkflowProps) {
const [doc, setDoc] = useState<ProcessDoc | null>(null)
const [loading, setLoading] = useState(!!processName)
const [error, setError] = useState('')
const load = useCallback(async () => {
if (!processName) { setLoading(false); return }
setLoading(true); setError('')
try {
const data = await fetchProcess(processName)
setDoc(data)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load process')
}
setLoading(false)
}, [processName])
useEffect(() => { void load() }, [load])
if (!processName) {
return <div style={{ ...S.card, color: '#aaa' }}>No process selected.</div>
}
if (loading) return <div style={{ padding: 32, color: '#aaa', textAlign: 'center' }}>Loading…</div>
if (error) return <div style={{ padding: 32, color: '#e74c3c' }}>Error: {error}</div>
if (!doc) return null
// Resolve role — accept both SB-prefixed and plain Frappe roles used by the workflow config
const rawRoles = (userRoles?.length ? userRoles : getUserRoles())
const isAuditor =
userRole === 'Auditor' || userRole === 'Admin' ||
rawRoles.some(r => ['SB Auditor', 'SB Admin', 'System Manager'].includes(r))
const isEmployee =
userRole === 'Employee' || userRole === 'Manager' ||
rawRoles.some(r => ['SB Employee', 'SB Manager', 'Employee'].includes(r))
const isOwnerOrAuditor =
isAuditor ||
userRole === 'Owner' ||
rawRoles.some(r => ['SB Owner', 'Owner'].includes(r))
// Treat null / missing workflow_state as 'Draft'
const state: string = (doc.workflow_state as string) || 'Draft'
// Info strip — always visible so role/state mismatch is self-diagnosing
const infoStrip = (
<div style={{ fontSize: 12, color: '#6b7280', marginBottom: 16, padding: '6px 10px', background: '#f8f9fa', borderRadius: 6, display: 'flex', gap: 20 }}>
<span>Process: <strong>{doc.name}</strong></span>
<span>State: <strong>{state}</strong></span>
<span>Role: <strong>{userRole || rawRoles.join(', ') || '—'}</strong></span>
</div>
)
if (isAuditor && state === 'Draft') {
return <><div style={S.card}>{infoStrip}</div><Step1Auditor doc={doc} onSuccess={load} /></>
}
if (isEmployee && state === 'Pending Actor') {
return <><div style={S.card}>{infoStrip}</div><Step2Employee doc={doc} onSuccess={load} /></>
}
if (isOwnerOrAuditor && state === 'Pending Comment') {
return <><div style={S.card}>{infoStrip}</div><Step3Comment doc={doc} onSuccess={load} /></>
}
if (isAuditor && state === 'Review') {
return <><div style={S.card}>{infoStrip}</div><Step4Review doc={doc} onSuccess={load} /></>
}
return (
<div style={S.card}>
<div style={S.title}>{doc.title}</div>
{infoStrip}
{state === 'In Study' ? (
<div>
<span style={S.stateBadge('In Study')}>In Study</span>
<p style={{ color: '#6b7280', fontSize: 14, marginTop: 8 }}>
This process has been moved to Study for formal analysis.
</p>
{doc.study_incident && (
<div style={S.ok}>Study reference: <strong>{doc.study_incident}</strong></div>
)}
{isAuditor && (
(doc as any).completion_pct === 100 || (doc as any).stage === 5 || doc.status === 'Completion' || doc.status === 'Completed' || doc.workflow_state === 'Closed' ? (
<button type="button"
onClick={async () => { try { await applyAction(doc.name, 'Close Process'); load() } catch(e: unknown) { console.error(e) } }}
style={{ ...S.btn('#475569'), marginTop: 12 }}>
Close Process
</button>
) : (
<button type="button"
onClick={async () => { try { await applyAction(doc.name, 'Start New Cycle'); load() } catch(e: unknown) { console.error(e) } }}
style={{ ...S.btn('#2563eb'), marginTop: 12 }}>
Start New Cycle
</button>
)
)}
</div>
) : (
<p style={{ color: '#6b7280', fontSize: 14 }}>
No workflow action available — your role (<strong>{userRole || rawRoles.join(', ') || '?'}</strong>) has no action in the <strong>{state}</strong> state.
</p>
)}
</div>
)
}

View File

@ -0,0 +1,14 @@
interface StatCardProps {
label: string
val: number
color: string
}
export default function StatCard({ label, val, color }: StatCardProps) {
return (
<div style={{ background: 'white', borderRadius: 8, padding: '8px 16px', flex: 1, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', borderTop: `3px solid ${color}`, minWidth: 0, display: 'flex', alignItems: 'baseline', gap: 8 }}>
<div style={{ fontSize: 18, fontWeight: 700, color }}>{val}</div>
<div style={{ fontSize: 12, color: '#999' }}>{label}</div>
</div>
)
}

View File

@ -0,0 +1,87 @@
interface StatusCardProps {
title: string
// [label, count, rawStatusValue] — rawStatusValue is what the target list page's
// status filter expects (may differ from the display label); '' on the Total row.
data: [string, number, string][]
color: string
// Called with a status's raw value when a segment/legend row is clicked, so the
// caller can open the target list pre-filtered to just that status.
onSelect?: (status: string) => void
}
// Lightens a hex color toward white — used for the unfilled bar track so the
// track reads as "the same color, dimmer" rather than a generic gray.
function tint(hex: string, amount: number): string {
const n = parseInt(hex.replace('#', ''), 16)
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255
const mix = (c: number) => Math.round(c + (255 - c) * amount)
return `rgb(${mix(r)}, ${mix(g)}, ${mix(b)})`
}
function rgba(hex: string, alpha: number): string {
const n = parseInt(hex.replace('#', ''), 16)
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
export default function StatusCard({ title, data, color, onSelect }: StatusCardProps) {
const [, total] = data[0] ?? ['Total', 0, '']
const breakdown = data.slice(1)
// Each status gets a step of the card's own hue (darkest = first entry) rather than
// a repeated bar per row — one composition bar up top, a dot-legend below it.
const shades = breakdown.map((_, i) => tint(color, Math.min(i * 0.16, 0.7)))
const segments = breakdown
.map(([label, count, status], i) => [label, count, status, shades[i]] as [string, number, string, string])
.filter(([, count]) => count > 0)
return (
<div className="status-card" style={{
borderRadius: 18, padding: '16px 18px 18px', height: '100%', boxSizing: 'border-box',
display: 'flex', flexDirection: 'column', minWidth: 0, position: 'relative', overflow: 'hidden',
}}>
{/* Decorative tint + accent line sit on their own layer so the card's own
background-color (theme-aware, from the .status-card class) stays intact
underneath instead of being replaced by an inline `background` shorthand. */}
<div style={{
position: 'absolute', inset: 0, pointerEvents: 'none',
background: `linear-gradient(160deg, ${rgba(color, 0.09)} 0%, transparent 55%)`,
}} />
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 3, background: `linear-gradient(90deg, ${color}, ${rgba(color, 0.35)})` }} />
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}>
<span className="status-card-title" style={{ fontSize: 13, fontWeight: 700, paddingTop: 4 }}>{title}</span>
<span style={{
fontSize: 14, fontWeight: 800, color: 'white', background: color, lineHeight: 1,
minWidth: 26, textAlign: 'center', padding: '6px 9px', borderRadius: 10,
boxShadow: `0 4px 10px ${rgba(color, 0.35)}`,
}}>
{total}
</span>
</div>
{total > 0 && (
<div style={{ display: 'flex', height: 9, borderRadius: 999, overflow: 'hidden', background: rgba(color, 0.1), marginBottom: 13 }}>
{segments.map(([label, count, status, seg], i) => (
<div key={label} title={`${label}: ${count}`}
onClick={e => { e.stopPropagation(); onSelect?.(status) }}
style={{ flex: `${count} 0 0`, background: seg, borderRight: i < segments.length - 1 ? '2px solid white' : 'none', cursor: onSelect ? 'pointer' : 'default' }} />
))}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{breakdown.map(([label, count, status], i) => (
<div key={label}
className="status-card-row"
onClick={count > 0 ? (e => { e.stopPropagation(); onSelect?.(status) }) : undefined}
style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '4px 6px', borderRadius: 8, cursor: count > 0 && onSelect ? 'pointer' : 'default', transition: 'background 0.12s' }}
>
<span style={{ width: 9, height: 9, borderRadius: '50%', background: shades[i], flexShrink: 0, boxShadow: `0 0 0 3px ${rgba(shades[i], 0.18)}` }} />
<span className="status-card-label" style={{ flex: 1, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<span className="status-card-count" style={{ fontSize: 12, fontWeight: 700, flexShrink: 0 }}>{count}</span>
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,106 @@
interface TableSelectionBarProps {
count: number
onClear: () => void
onExport?: (format: 'csv' | 'xlsx') => void
}
export default function TableSelectionBar({ count, onClear, onExport }: TableSelectionBarProps) {
if (count === 0) return null
return (
<div
style={{
position: 'sticky',
bottom: 20,
zIndex: 50,
margin: '0 auto',
width: 'fit-content',
maxWidth: '92%',
background: '#0f172a',
color: '#f8fafc',
padding: '8px 18px',
borderRadius: 9999,
boxShadow: '0 12px 32px rgba(0, 0, 0, 0.35), 0 2px 6px rgba(0,0,0,0.2)',
display: 'flex',
alignItems: 'center',
gap: 14,
fontSize: 13,
animation: 'fadeIn 0.2s ease',
border: '1px solid #334155',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 700, color: '#38bdf8' }}>
<span style={{ fontSize: 14 }}>✓</span>
<span>{count} selected</span>
</div>
{onExport && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
type="button"
onClick={() => onExport('csv')}
style={{
background: '#16a34a',
color: 'white',
border: 'none',
borderRadius: 20,
padding: '5px 12px',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 5,
transition: 'background 0.15s ease',
}}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = '#15803d' }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = '#16a34a' }}
>
<span>📤</span> Export CSV
</button>
<button
type="button"
onClick={() => onExport('xlsx')}
style={{
background: '#0284c7',
color: 'white',
border: 'none',
borderRadius: 20,
padding: '5px 12px',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 5,
transition: 'background 0.15s ease',
}}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = '#0369a1' }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = '#0284c7' }}
>
<span>📊</span> Export Excel
</button>
</div>
)}
<button
type="button"
onClick={onClear}
style={{
background: 'transparent',
color: '#94a3b8',
border: 'none',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
padding: '4px 6px',
transition: 'color 0.15s ease',
}}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.color = '#f1f5f9' }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.color = '#94a3b8' }}
>
Clear
</button>
</div>
)
}

View File

@ -0,0 +1,223 @@
import { useState, useRef, useEffect } from 'react'
import { getNotifications, markAllNotificationsRead, markNotificationRead } from '../api'
import { toggleLang, isRTL } from '../i18n'
import { Page } from '../types'
interface Notif {
name: string; subject: string; type?: string
document_type?: string; document_name?: string
read: number; creation: string
}
interface Props {
notifCount: number
darkMode: boolean
onCountChange: (n: number) => void
onToggleDark: () => void
onViewProfile: () => void
onLogout: () => void
onNavigate?: (page: Page, name?: string) => void
userName?: string
userRole?: string
}
const NOTIF_DOCTYPE_TO_PAGE: Record<string, Page> = {
'SB Process': 'processes-form',
'SB Order': 'orders-form',
'SB Incident': 'incidents-form',
'SB Idea': 'ideas-form',
'SB Delegation': 'delegations',
}
function timeAgo(dateStr: string): string {
const diff = (Date.now() - new Date(dateStr).getTime()) / 1000
if (diff < 60) return `${Math.floor(diff)}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
export default function TopBar({ notifCount, darkMode, onCountChange, onToggleDark, onViewProfile, onLogout, onNavigate, userName, userRole }: Props) {
const [bellOpen, setBellOpen] = useState(false)
const [notifs, setNotifs] = useState<Notif[]>([])
const [loading, setLoading] = useState(false)
const bellRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (bellRef.current && !bellRef.current.contains(e.target as Node)) setBellOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const loadNotifs = async () => {
setLoading(true)
const data = await getNotifications(30)
setNotifs(data)
setLoading(false)
}
const handleBellOpen = () => {
const next = !bellOpen
setBellOpen(next)
if (next) loadNotifs()
}
const handleMarkAll = async () => {
await markAllNotificationsRead()
setNotifs(prev => prev.map(n => ({ ...n, read: 1 })))
onCountChange(0)
}
const handleNotifClick = (n: Notif) => {
if (!n.read) {
markNotificationRead(n.name)
setNotifs(prev => prev.map(x => x.name === n.name ? { ...x, read: 1 } : x))
onCountChange(Math.max(0, notifCount - 1))
}
const page = n.document_type ? NOTIF_DOCTYPE_TO_PAGE[n.document_type] : undefined
if (page && onNavigate) {
onNavigate(page, n.document_type === 'SB Delegation' ? undefined : n.document_name)
setBellOpen(false)
}
}
const unread = notifs.filter(n => !n.read).length
const iconBtn = (overrides?: React.CSSProperties): React.CSSProperties => ({
position: 'relative', width: 34, height: 34,
display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: '50%', border: 'none', cursor: 'pointer',
background: darkMode ? '#2d3748' : '#f3f4f6',
color: darkMode ? '#e2e8f0' : '#6b7280',
fontSize: 14, transition: 'background 0.15s', flexShrink: 0,
...overrides,
})
const dm = darkMode
const dropBg = dm ? '#1a202c' : 'white'
const dropBorder = dm ? '#2d3748' : '#e5e7eb'
const textColor = dm ? '#e2e8f0' : '#1a2332'
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{/* Who is logged in — name + role, shown just before the profile icon */}
{userName && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', marginRight: 4, lineHeight: 1.25 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: dm ? '#e2e8f0' : '#1e293b', whiteSpace: 'nowrap' }}>{userName}</span>
{userRole && <span style={{ fontSize: 11, color: dm ? '#94a3b8' : '#6b7280', whiteSpace: 'nowrap' }}>{userRole}</span>}
</div>
)}
{/* Profile avatar icon */}
<button style={iconBtn({ background: '#4f46e5', color: 'white' })} onClick={onViewProfile} title="My Profile">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="8" r="4"/>
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" fill="currentColor"/>
</svg>
</button>
{/* Notification bell */}
<div ref={bellRef} style={{ position: 'relative' }}>
<button style={iconBtn()} onClick={handleBellOpen} title="Notifications">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
{notifCount > 0 && (
<span style={{ position: 'absolute', top: 2, right: 2, background: '#ef4444', color: 'white', fontSize: 9, fontWeight: 700, borderRadius: 999, minWidth: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 3px', lineHeight: 1 }}>
{notifCount > 9 ? '9+' : notifCount}
</span>
)}
</button>
{bellOpen && (
<div style={{ position: 'absolute', right: 0, top: 'calc(100% + 8px)', width: 340, background: dropBg, borderRadius: 12, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', zIndex: 10000, border: `1px solid ${dropBorder}`, overflow: 'hidden' }}>
<div style={{ padding: '12px 16px', borderBottom: `1px solid ${dropBorder}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: dropBg }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, color: textColor }}>
Notifications
{unread > 0 && <span style={{ background: '#ef4444', color: 'white', fontSize: 10, fontWeight: 700, borderRadius: 999, padding: '2px 7px' }}>{unread} new</span>}
</div>
{unread > 0 && (
<button onClick={handleMarkAll} style={{ fontSize: 12, color: '#3498db', background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
Mark all read
</button>
)}
</div>
<div style={{ maxHeight: 380, overflowY: 'auto' }}>
{loading && <div style={{ padding: 20, textAlign: 'center', color: '#aaa', fontSize: 13 }}>Loading…</div>}
{!loading && notifs.length === 0 && (
<div style={{ padding: '36px 20px', textAlign: 'center', color: '#aaa' }}>
<div style={{ fontSize: 28, marginBottom: 8 }}>🔔</div>
<div style={{ fontSize: 13 }}>No notifications</div>
</div>
)}
{!loading && notifs.map(n => {
const clickable = !!(n.document_type && NOTIF_DOCTYPE_TO_PAGE[n.document_type] && onNavigate)
return (
<div key={n.name} onClick={() => handleNotifClick(n)}
style={{
padding: '11px 16px', borderBottom: `1px solid ${dm ? '#2d3748' : '#f5f5f5'}`, display: 'flex', gap: 10,
background: n.read ? dropBg : (dm ? '#1e3a5f' : '#eff6ff'),
cursor: clickable ? 'pointer' : 'default', transition: 'background 0.12s',
}}
onMouseEnter={e => { if (clickable) e.currentTarget.style.background = dm ? '#243244' : '#e0ecfd' }}
onMouseLeave={e => { e.currentTarget.style.background = n.read ? dropBg : (dm ? '#1e3a5f' : '#eff6ff') }}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: n.read ? 400 : 600, color: textColor, marginBottom: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{n.subject || `${n.document_type}: ${n.document_name}`}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{n.document_type && (
<span style={{ fontSize: 10, fontWeight: 700, color: '#3b82f6', background: dm ? 'rgba(59,130,246,0.15)' : '#eff6ff', padding: '1px 7px', borderRadius: 8 }}>
{n.document_type.replace('SB ', '')}{n.document_name ? ` · ${n.document_name}` : ''}
</span>
)}
</div>
<div style={{ fontSize: 11, color: '#aaa', marginTop: 3 }}>{timeAgo(n.creation)}</div>
</div>
{!n.read && <div style={{ width: 8, height: 8, borderRadius: '50%', background: '#3498db', flexShrink: 0, marginTop: 5 }} />}
</div>
)
})}
</div>
</div>
)}
</div>
{/* Language toggle */}
<button style={iconBtn()} onClick={toggleLang} title={isRTL() ? 'Switch to English' : 'Switch to Arabic'}>
<span style={{ fontSize: 13, fontWeight: 700, fontFamily: 'serif' }}>عA</span>
</button>
{/* Dark mode toggle */}
<button style={iconBtn()} onClick={onToggleDark} title={darkMode ? 'Light mode' : 'Dark mode'}>
{darkMode ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
)}
</button>
{/* Logout */}
<button style={iconBtn({ background: dm ? '#3d1515' : '#fee2e2', color: '#ef4444' })} onClick={onLogout} title="Logout">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16,17 21,12 16,7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
)
}

View File

@ -0,0 +1,127 @@
import { useEffect, useRef, useState } from 'react'
import { User } from '../types'
import { useT, toggleLang } from '../i18n'
interface Props {
user: User
sbUsers?: any[]
onLogout: () => void
onViewProfile?: () => void
}
const ROLE_COLOR: Record<string, string> = {
Manager: '#2563eb',
Auditor: '#d97706',
Employee: '#6b7280',
Admin: '#7c3aed',
'X-Auditor': '#0f766e',
Chairman: '#0f766e',
}
export default function UserMenu({ user, sbUsers = [], onLogout, onViewProfile }: Props) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const t = useT()
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const sbUser = sbUsers.find(u => u.name === user.name || u.username === user.name)
const category = sbUser?.custom_category || user.role || 'User'
const initials = (user.full_name || user.name || 'U').charAt(0).toUpperCase()
const roleColor = ROLE_COLOR[user.role] || '#6b7280'
return (
<div ref={ref} style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: 8 }}>
<button
onClick={() => setOpen(o => !o)}
style={{
display: 'flex', alignItems: 'center', gap: 7,
background: open ? '#f1f5f9' : 'transparent',
border: '1px solid transparent',
borderRadius: 20, padding: '4px 10px 4px 5px',
cursor: 'pointer', transition: 'background 0.15s',
}}>
<div style={{
width: 28, height: 28, borderRadius: '50%',
background: roleColor, color: 'white',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12, fontWeight: 700, flexShrink: 0,
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}}>
{initials}
</div>
<span style={{ fontSize: 12, fontWeight: 600, color: '#374151', whiteSpace: 'nowrap', maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{user.full_name || user.name}
</span>
<span style={{ fontSize: 9, color: '#9ca3af' }}>{open ? '▲' : '▼'}</span>
</button>
{open && (
<div style={{
position: 'absolute', right: 0, top: 'calc(100% + 6px)',
width: 220, background: 'white', borderRadius: 10,
boxShadow: '0 8px 32px rgba(0,0,0,0.15)', zIndex: 10000,
border: '1px solid #e5e7eb', overflow: 'hidden',
}}>
<div style={{ padding: '14px 16px', borderBottom: '1px solid #f0f0f0', background: '#f8fafc', display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: '50%', background: roleColor,
color: 'white', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 15, fontWeight: 700, flexShrink: 0,
}}>
{initials}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#1a2332', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{user.full_name || user.name}
</div>
<div style={{ fontSize: 10, color: '#888', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{user.name}
</div>
<div style={{
display: 'inline-block', marginTop: 4, padding: '1px 8px',
background: roleColor + '18', color: roleColor,
fontSize: 10, fontWeight: 700, borderRadius: 8, border: '1px solid ' + roleColor + '44',
}}>
{category}
</div>
</div>
</div>
<div style={{ padding: '4px 0' }}>
<MenuRow icon="👤" label={t.myProfile} onClick={() => { onViewProfile?.(); setOpen(false) }} />
<MenuRow
icon="🌐"
label={t.switchLang}
onClick={() => { toggleLang(); setOpen(false) }}
/>
<div style={{ height: 1, background: '#f0f0f0', margin: '4px 8px' }} />
<MenuRow icon="↩" label={t.signOut} color="#ef4444" onClick={() => { onLogout(); setOpen(false) }} />
</div>
</div>
)}
</div>
)
}
function MenuRow({ icon, label, color = '#374151', onClick }: { icon: string; label: string; color?: string; onClick: () => void }) {
const [hover, setHover] = useState(false)
return (
<div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
style={{
padding: '8px 14px', fontSize: 13, color, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 9,
background: hover ? (color === '#ef4444' ? '#fef2f2' : '#f9fafb') : 'white',
transition: 'background 0.1s',
}}>
<span style={{ fontSize: 14, width: 18, textAlign: 'center' }}>{icon}</span>
{label}
</div>
)
}

View File

@ -0,0 +1,287 @@
import { useEffect, useState, useCallback } from 'react'
import { fetchProcess, applyAction, ProcessDoc } from '../utils/processAPI'
interface Props {
processName: string
userRole: string // 'Manager' | 'Auditor' | 'Employee'
onRefresh?: () => void
canUpdateStatus?: boolean // BRD 4.3 — false when a Manager is neither the assigned Actor nor delegated
}
interface Transition {
action: string
next: string
allowedRoles: string[]
}
const TRANSITIONS: Record<string, Transition[]> = {
Draft: [
{ action: 'Submit for Review', next: 'Pending Actor', allowedRoles: ['Auditor', 'Manager'] },
],
'Pending Actor': [
{ action: 'Update Status', next: 'Pending Comment', allowedRoles: ['Employee', 'Manager'] },
],
'Pending Comment': [
{ action: 'Add Comment', next: 'Review', allowedRoles: ['Auditor', 'Manager'] },
],
Review: [
{ action: 'Start New Cycle', next: 'Pending Actor', allowedRoles: ['Auditor', 'Manager'] },
{ action: 'Move to Study', next: 'In Study', allowedRoles: ['Auditor', 'Manager'] },
{ action: 'Close Process', next: 'Closed', allowedRoles: ['Auditor', 'Manager'] },
],
'In Study': [
{ action: 'Start New Cycle', next: 'Pending Actor', allowedRoles: ['Auditor', 'Manager'] },
{ action: 'Close Process', next: 'Closed', allowedRoles: ['Auditor', 'Manager'] },
],
}
const STATE_BADGE: Record<string, React.CSSProperties> = {
Draft: { background: '#eff6ff', color: '#1e40af', border: '1px solid #bfdbfe' },
'Pending Actor': { background: '#fff7ed', color: '#c2410c', border: '1px solid #fed7aa' },
'Pending Comment': { background: '#f5f3ff', color: '#6d28d9', border: '1px solid #ddd6fe' },
Review: { background: '#f0fdf4', color: '#15803d', border: '1px solid #bbf7d0' },
'In Study': { background: '#f1f5f9', color: '#475569', border: '1px solid #cbd5e1' },
Completed: { background: '#ecfdf5', color: '#047857', border: '1px solid #a7f3d0' },
'Closed': { background: '#f3f4f6', color: '#6b7280', border: '1px solid #9ca3af' },
}
function badge(state: string): React.CSSProperties {
return {
display: 'inline-block', padding: '5px 14px', borderRadius: 20, fontSize: 13, fontWeight: 700,
...(STATE_BADGE[state] ?? { background: '#f3f4f6', color: '#374151', border: '1px solid #e5e7eb' }),
}
}
const inp: React.CSSProperties = {
width: '100%', padding: '6px 8px', border: '1px solid #d1d5db',
borderRadius: 5, fontSize: 12, fontFamily: 'Segoe UI', boxSizing: 'border-box',
}
export default function WorkflowPanel({ processName, userRole, onRefresh, canUpdateStatus = true }: Props) {
const [doc, setDoc] = useState<ProcessDoc | null>(null)
const [fetchLoading, setFetchLoading] = useState(true)
const [fetchError, setFetchError] = useState('')
const [actionLoading, setActionLoading] = useState(false)
const [actionError, setActionError] = useState('')
const [actionSuccess, setActionSuccess] = useState('')
const [actorStatus, setActorStatus] = useState<'Pass' | 'Fail'>('Pass')
const [actorComment, setActorComment] = useState('')
const [indicatorValues, setIndicatorValues] = useState<Record<string, string>>({})
const load = useCallback(async () => {
if (!processName) { setFetchLoading(false); return }
setFetchLoading(true); setFetchError('')
try {
const d = await fetchProcess(processName)
setDoc(d)
const vals: Record<string, string> = {}
for (const row of (d.progress_indicators || []) as any[]) {
vals[row.name] = row.actual_value != null ? String(row.actual_value) : ''
}
setIndicatorValues(vals)
} catch (e: unknown) {
setFetchError(e instanceof Error ? e.message : 'Failed to load process')
}
setFetchLoading(false)
}, [processName])
useEffect(() => { load() }, [load])
if (fetchLoading) return (
<div style={{ width: '100%', flexShrink: 0 }}>
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', padding: 24, color: '#aaa', fontSize: 13, textAlign: 'center' }}>Loading…</div>
</div>
)
if (fetchError || !doc) return (
<div style={{ width: '100%', flexShrink: 0 }}>
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', padding: 16 }}>
<div style={{ fontSize: 12, color: '#991b1b', background: '#fef2f2', border: '1px solid #fca5a5', borderRadius: 6, padding: '8px 12px' }}>
{fetchError || 'Could not load workflow'}
</div>
</div>
</div>
)
const state = (doc.workflow_state as string) || 'Draft'
const transitions = TRANSITIONS[state] ?? []
const isActor = userRole === 'Employee' || userRole === 'Manager'
const canShowUpdateStatus = userRole !== 'Manager' || canUpdateStatus
const isCompleted = (doc as any).completion_pct === 100 || (doc as any).stage === 5 || doc.status === 'Completion' || doc.status === 'Completed' || state === 'Completed' || state === 'Closed'
const available = transitions.filter(t => {
if (t.action === 'Start New Cycle' && isCompleted) return false
return (userRole === 'Admin' || t.allowedRoles.includes(userRole)) &&
(t.action !== 'Update Status' || canShowUpdateStatus)
})
const runAction = async (action: string) => {
setActionLoading(true); setActionError(''); setActionSuccess('')
try {
const extra: Record<string, unknown> = {}
if (action === 'Update Status') {
extra.actor_status = actorStatus
extra.actor_comment = actorComment
const indicators = (doc.progress_indicators || []) as any[]
if (indicators.length > 0) {
extra.progress_indicators = indicators.map(row => ({
name: row.name,
actual_value: indicatorValues[row.name] ?? '',
}))
}
}
if (action === 'Add Comment' && actorComment) {
extra.actor_comment = actorComment
}
const updated = await applyAction(doc.name, action, extra)
setDoc(updated)
setActorComment('')
setActionSuccess(`Done — now: ${updated.workflow_state || 'updated'}`)
onRefresh?.()
} catch (e: unknown) {
setActionError(e instanceof Error ? e.message : 'Action failed')
}
setActionLoading(false)
}
return (
<div style={{ width: '100%', flexShrink: 0 }}>
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'hidden' }}>
{/* Header */}
<div style={{ background: '#1a2332', color: 'white', padding: '10px 14px', fontSize: 12, fontWeight: 700, letterSpacing: 0.3 }}>
Workflow
</div>
{/* State badge */}
<div style={{ padding: '12px 14px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: 10, color: '#999', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 8 }}>Current State</div>
<div style={badge(state)}>{state}</div>
</div>
{/* Pending Actor: Actor selects Pass/Fail manually (BRD 7.2) */}
{state === 'Pending Actor' && isActor && canShowUpdateStatus && (
<div style={{ padding: '12px 14px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: 10, color: '#999', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 10 }}>
Update Status
</div>
{Array.isArray(doc.progress_indicators) && (doc.progress_indicators as any[]).length > 0 && (
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 11, color: '#555', marginBottom: 4 }}>Actual Value</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{(doc.progress_indicators as any[]).map(row => (
<div key={row.name} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 11, color: '#555', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={row.name1}>{row.name1 || row.name}</span>
<input
type="number"
value={indicatorValues[row.name] ?? ''}
onChange={e => setIndicatorValues(v => ({ ...v, [row.name]: e.target.value }))}
style={{ ...inp, width: 70, flexShrink: 0 }}
placeholder="0" />
</div>
))}
</div>
</div>
)}
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, color: '#555', marginBottom: 4 }}>Cycle Status</div>
<select
value={actorStatus}
onChange={e => setActorStatus(e.target.value as 'Pass' | 'Fail')}
style={inp}>
<option value="Pass">✅ Pass — on target</option>
<option value="Fail">❌ Fail — off target</option>
</select>
</div>
<div>
<div style={{ fontSize: 11, color: '#555', marginBottom: 4 }}>Comment (optional)</div>
<textarea
value={actorComment}
onChange={e => setActorComment(e.target.value)}
style={{ ...inp, height: 52, resize: 'vertical' }}
placeholder="Add a comment…" />
</div>
</div>
)}
{/* Pending Comment / Review / Add Comment: show Auditor comment box */}
{(state === 'Pending Comment' || state === 'Review') && !isActor && (
<div style={{ padding: '12px 14px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ fontSize: 10, color: '#999', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 10 }}>
Auditor Comment
</div>
{Array.isArray(doc.cycle_logs) && (doc.cycle_logs as any[]).length > 0 && (
<div style={{ marginBottom: 8, padding: '6px 10px', background: '#f8fafc', borderRadius: 6, border: '1px solid #e2e8f0' }}>
<div style={{ fontSize: 10, color: '#999', marginBottom: 3 }}>Latest cycle status:</div>
{(() => {
const logs = doc.cycle_logs as any[]
const last = logs[logs.length - 1]
const color = last.status === 'Pass' ? '#16a34a' : last.status === 'Fail' ? '#dc2626' : '#9ca3af'
return <div style={{ fontSize: 13, fontWeight: 700, color }}>{last.status} {last.comment ? `— ${last.comment}` : ''}</div>
})()}
</div>
)}
<textarea
value={actorComment}
onChange={e => setActorComment(e.target.value)}
style={{ ...inp, height: 52, resize: 'vertical' }}
placeholder="Add a comment…" />
</div>
)}
{/* Action buttons */}
<div style={{ padding: '12px 14px', borderBottom: available.length > 0 ? '1px solid #f0f0f0' : 'none' }}>
<div style={{ fontSize: 10, color: '#999', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 10 }}>
Actions
</div>
{available.length === 0 && (
<div style={{ fontSize: 12, color: '#aaa', fontStyle: 'italic', lineHeight: 1.5 }}>
No actions for <strong>{userRole}</strong> in <strong>{state}</strong>.
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{available.map(t => (
<button key={t.action} onClick={() => runAction(t.action)} disabled={actionLoading}
style={{
width: '100%', padding: '10px 14px', borderRadius: 6, border: 'none',
background: t.action === 'Close Process' ? '#64748b' : t.action === 'Move to Study' ? '#8e44ad' : '#2563eb',
color: 'white', cursor: actionLoading ? 'not-allowed' : 'pointer',
fontSize: 13, fontWeight: 600, opacity: actionLoading ? 0.7 : 1,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span>{actionLoading ? 'Applying…' : t.action}</span>
<span style={{ fontSize: 10, opacity: 0.7 }}>→ {t.next}</span>
</button>
))}
</div>
{actionError && (
<div style={{ marginTop: 10, padding: '8px 12px', background: '#fef2f2', border: '1px solid #fca5a5', borderRadius: 6, fontSize: 12, color: '#991b1b', lineHeight: 1.4 }}>
{actionError}
</div>
)}
{actionSuccess && (
<div style={{ marginTop: 10, padding: '8px 12px', background: '#f0fdf4', border: '1px solid #86efac', borderRadius: 6, fontSize: 12, color: '#166534' }}>
{actionSuccess}
</div>
)}
</div>
{/* State map */}
{transitions.length > 0 && (
<div style={{ padding: '12px 14px' }}>
<div style={{ fontSize: 10, color: '#999', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 10 }}>Next States</div>
{transitions.map(t => (
<div key={t.action} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, fontSize: 11 }}>
<span style={{ color: '#555', flex: 1 }}>{t.action}</span>
<span style={{ color: '#bbb' }}>→</span>
<span style={badge(t.next)}>{t.next}</span>
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,643 @@
import React from 'react'
// ── Action icon SVGs ──────────────────────────────────────────────────────────
const EyeIcon = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
)
const PencilIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
)
const TrashIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/>
</svg>
)
interface RowActionsProps {
onView?: () => void
onEdit?: () => void
onDelete?: () => void
}
export function RowActions({ onView, onEdit, onDelete }: RowActionsProps) {
const btn = (bg: string, hoverBg: string, color: string, handler: (() => void) | undefined, icon: React.ReactNode, title: string) =>
handler ? (
<button
key={title}
type="button"
onClick={e => { e.stopPropagation(); handler() }}
title={title}
style={{
width: 30,
height: 30,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
border: '1px solid transparent',
cursor: 'pointer',
background: bg,
color,
flexShrink: 0,
transition: 'all 0.15s ease',
}}
onMouseEnter={e => {
e.currentTarget.style.background = hoverBg
e.currentTarget.style.transform = 'scale(1.08)'
}}
onMouseLeave={e => {
e.currentTarget.style.background = bg
e.currentTarget.style.transform = 'scale(1)'
}}
>{icon}</button>
) : null
return (
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{btn('#f0f9ff', '#e0f2fe', '#0284c7', onView, <EyeIcon />, 'View')}
{btn('#f0fdf4', '#dcfce7', '#16a34a', onEdit, <PencilIcon />, 'Edit')}
{onDelete && btn('#fff1f2', '#fee2e2', '#e11d48', onDelete, <TrashIcon />, 'Delete')}
</div>
)
}
// ✅ ALL COLORS
export const colors = {
bg: '#f5f6fa',
sidebar: '#1e1b4b',
primary: '#4f46e5',
secondary: '#0d9488',
red: '#ef4444',
orange: '#f59e0b',
green: '#16a34a',
purple: '#7c3aed',
blue: '#2563eb',
teal: '#0d9488',
gray: '#94a3b8',
dark: '#1e1b4b',
light: '#f5f5f5',
text: '#4b5563',
danger: '#ef4444',
accent: '#4f46e5'
}
// Explicit height (not just vertical padding) so every field — <input>, <select>,
// and the custom LinkSelect/OrgSelect trigger divs — locks to the same box size
// regardless of each element's own intrinsic line-height/UA quirks.
export const inp: React.CSSProperties = {
width: '100%',
height: 34,
padding: '7px 10px',
border: '1px solid #ddd',
borderRadius: 6,
fontSize: 12,
boxSizing: 'border-box',
fontFamily: 'Segoe UI, Arial, sans-serif'
}
export const lbl: React.CSSProperties = {
display: 'block',
marginBottom: 5,
fontSize: 11,
color: '#555',
fontWeight: 600
}
export function isSoftDeleted(item: any): boolean {
if (!item) return false
if (item.custom_is_deleted === 1 || item.custom_is_deleted === '1' || item.custom_is_deleted === true) return true
if (item.is_deleted === 1 || item.is_deleted === '1' || item.is_deleted === true) return true
if (item.status === 'Deleted' || item.custom_status === 'Deleted') return true
if (item.enabled === 0 || item.enabled === false) return true
if (item.docstatus === 2) return true
return false
}
// True unless the master record has been explicitly marked Inactive/disabled.
function isActiveRecord(item: any): boolean {
if (isSoftDeleted(item)) return false
if (typeof item?.is_active !== 'undefined') return !!item.is_active
if (typeof item?.status !== 'undefined') return item.status !== 'Inactive'
if (typeof item?.custom_status !== 'undefined') return item.custom_status !== 'Inactive'
return true
}
// Hides inactive master records (Types/Statuses/Managements/Departments/Branches/Cycles/...)
// from selection. Soft deleted records are NEVER shown for any user.
export function activeItems<T extends { name: string }>(items: T[], selected?: string): T[] {
return (items || []).filter(i => !isSoftDeleted(i) && (isActiveRecord(i) || i.name === selected))
}
// Terminal workflow_state values per doctype — once a record reaches one of these,
// no further transitions exist (see sidrah_business/fixtures/workflow.json and
// WorkflowPanel.tsx's TRANSITIONS map), so it must never be re-opened for editing.
const TERMINAL_WORKFLOW_STATES: Record<'incident' | 'idea' | 'order' | 'process', string[]> = {
incident: ['Rejected', 'Executed & Closed'],
idea: ['Rejected', 'Executed & Closed'],
order: ['Closed'],
process: ['Closed'],
}
export function isWorkflowClosed(doctype: 'incident' | 'idea' | 'order' | 'process', workflowState?: string): boolean {
return !!workflowState && TERMINAL_WORKFLOW_STATES[doctype].includes(workflowState)
}
// Filter master records by organization hierarchy
export function filterOrgItems<T extends Record<string, any>>(
items: T[],
filters: { organization?: string; management?: string; department?: string }
): T[] {
return (items || []).filter(item => {
if (!item || isSoftDeleted(item)) return false
const itemAny = item as any
const org = itemAny.organization || itemAny.custom_organization
if (filters.organization && org && org !== filters.organization) return false
const mgmt = itemAny.management || itemAny.custom_management
if (filters.management && mgmt && mgmt !== filters.management) return false
const dept = itemAny.department || itemAny.custom_department
if (filters.department && dept && dept !== filters.department) return false
return true
})
}
// Users holding `role`, excluding anyone who is also an Admin (SB Admin / System Manager).
// When `organization` is passed (the form's own Organization field), also excludes users
// pinned to a *different* organization — a user with no organization set is unrestricted
// and stays eligible for every organization's Actor/Assigned-To pickers.
export function usersWithRole(users: any[], roleMap: Record<string, string[]>, role: string, organization?: string): any[] {
return (users || []).filter(u => {
if (!u || isSoftDeleted(u) || u.enabled === 0 || u.enabled === false) return false
const roles = roleMap[u.name] || []
const cat = u.custom_category || ''
const catMatches = (
(role === 'SB Manager' && cat === 'Manager') ||
(role === 'SB Employee' && cat === 'Employee') ||
(role === 'SB Auditor' && (cat === 'Auditor' || cat === 'X-Auditor')) ||
(role === 'SB Admin' && cat === 'Admin') ||
((role === 'CEO' || role === 'SB CEO') && (cat === 'CEO' || cat === 'Chairman'))
)
if (roles.includes('SB Admin') || roles.includes('System Manager') || cat === 'Admin') {
if (role !== 'SB Admin') return false
}
if (!roles.includes(role) && !catMatches) return false
if (organization && u.custom_organization && u.custom_organization !== organization) return false
return true
})
}
export const chk: React.CSSProperties = {
width: 16,
height: 16,
cursor: 'pointer',
accentColor: '#2563eb',
flexShrink: 0,
}
interface FormRowProps {
children: React.ReactNode[] | React.ReactNode
}
export function FormRow({ children }: FormRowProps) {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(230px, 1fr))', gap: 14, marginBottom: 13 }}>
{children}
</div>
)
}
interface FormFieldProps {
label: string
required?: boolean
children: React.ReactNode
fullWidth?: boolean
}
export function FormField({ label, required, children, fullWidth }: FormFieldProps) {
return (
<div style={{ gridColumn: fullWidth ? '1 / -1' : 'auto' }}>
<label style={lbl}>
{label}
{required && <span style={{ color: colors.red }}>*</span>}
</label>
{children}
</div>
)
}
export interface BadgeTheme {
bg: string
color: string
border: string
}
export function getStatusTheme(status: string): BadgeTheme {
const s = (status || '').trim().toLowerCase()
if (s === 'new' || s === 'initial evaluation' || s === 'draft') {
return { bg: '#eff6ff', color: '#1d4ed8', border: '#bfdbfe' } // Soft Blue
}
if (s.includes('progress') || s.includes('review') || s.includes('proposal') || s.includes('resolution')) {
return { bg: '#fefce8', color: '#a16207', border: '#fef08a' } // Soft Amber
}
if (s.includes('executed') || s.includes('approved') || s.includes('active') || s.includes('closed') || s.includes('completed')) {
return { bg: '#f0fdf4', color: '#15803d', border: '#bbf7d0' } // Soft Emerald
}
if (s.includes('rejected') || s.includes('overdue') || s.includes('fail') || s.includes('inactive')) {
return { bg: '#fef2f2', color: '#b91c1c', border: '#fecaca' } // Soft Red
}
if (s.includes('deleted')) {
return { bg: '#fff1f2', color: '#e11d48', border: '#fecdd3' } // Soft Rose
}
return { bg: '#f8fafc', color: '#475569', border: '#e2e8f0' } // Soft Slate
}
export function getTypeTheme(type: string): BadgeTheme {
const t = (type || '').trim().toLowerCase()
if (t.includes('incident')) {
return { bg: '#f1f5f9', color: '#334155', border: '#cbd5e1' } // Soft Slate
}
if (t.includes('complaint')) {
return { bg: '#fff7ed', color: '#c2410c', border: '#fed7aa' } // Soft Orange
}
if (t.includes('violation')) {
return { bg: '#faf5ff', color: '#7e22ce', border: '#e9d5ff' } // Soft Purple
}
if (t.includes('process')) {
return { bg: '#f0fdfa', color: '#0f766e', border: '#99f6e4' } // Soft Teal
}
if (t.includes('asset')) {
return { bg: '#ecfeff', color: '#0e7490', border: '#a5f3fc' } // Soft Cyan
}
if (t.includes('human')) {
return { bg: '#f5f3ff', color: '#6d28d9', border: '#ddd6fe' } // Soft Violet
}
if (t.includes('idea')) {
return { bg: '#ecfdf5', color: '#047857', border: '#a7f3d0' } // Soft Emerald
}
if (t.includes('study')) {
return { bg: '#eff6ff', color: '#1d4ed8', border: '#bfdbfe' } // Soft Sky Blue
}
return { bg: '#f8fafc', color: '#475569', border: '#e2e8f0' } // Soft Neutral
}
export const badge = (label: string, colorOrTheme?: string | BadgeTheme) => {
let theme: BadgeTheme
if (typeof colorOrTheme === 'object' && colorOrTheme !== null) {
theme = colorOrTheme
} else if (typeof colorOrTheme === 'string' && colorOrTheme.startsWith('#')) {
// If a hex color is passed, adapt it to a soft theme
theme = getStatusTheme(label)
} else if (typeof colorOrTheme === 'string') {
theme = getStatusTheme(colorOrTheme)
} else {
theme = getStatusTheme(label)
}
return (
<span
className="status-badge"
style={{
background: theme.bg,
color: theme.color,
border: `1px solid ${theme.border}`,
padding: '2px 8px',
borderRadius: 9999,
fontSize: 11,
fontWeight: 600,
letterSpacing: '0.2px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: 1.3,
whiteSpace: 'nowrap',
boxSizing: 'border-box',
transition: 'all 0.15s ease',
}}
>
{label}
</span>
)
}
export const statusBadge = (s: string) => badge(s, getStatusTheme(s))
export const typeBadge = (t: string) => badge(t, getTypeTheme(t))
export const statusColor = (s: string): string => {
return getStatusTheme(s).color
}
export const ideaStatusColor = (s: string): string => {
return getStatusTheme(s).color
}
export const typeColor = (t: string): string => {
return getTypeTheme(t).color
}
export const categoryColor = (c: string): string => {
return getTypeTheme(c).color
}
interface DeletedBannerProps {
deletedBy?: string
deletedOn?: string
reason?: string
onRestore?: () => void
onPermanentDelete?: () => void
isRestoring?: boolean
}
export function DeletedBanner({ deletedBy, deletedOn, reason, onRestore, onPermanentDelete, isRestoring }: DeletedBannerProps) {
return (
<div style={{
background: '#fff1f2',
border: '1px solid #fecdd3',
borderRadius: 8,
padding: '7px 14px',
marginBottom: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
color: '#9f1239',
fontSize: 12,
flexWrap: 'wrap',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<div style={{ fontWeight: 700, fontSize: 12, display: 'flex', alignItems: 'center', gap: 5, color: '#be123c', whiteSpace: 'nowrap' }}>
<span style={{ fontSize: 13 }}>⚠️</span>
<span>DELETED RECORD / سجل محذوف</span>
</div>
<div style={{ fontSize: 11, color: '#881337', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{deletedBy && <span><strong>Deleted By:</strong> {deletedBy}</span>}
{deletedOn && <span>| <strong>Deleted On:</strong> {deletedOn}</span>}
{reason && <span>| <strong>Reason:</strong> {reason}</span>}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{onRestore && (
<button
type="button"
onClick={onRestore}
disabled={isRestoring}
style={{
background: '#16a34a',
color: 'white',
border: 'none',
borderRadius: 6,
padding: '5px 12px',
fontSize: 11,
fontWeight: 700,
cursor: isRestoring ? 'not-allowed' : 'pointer',
boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
display: 'flex',
alignItems: 'center',
gap: 5,
whiteSpace: 'nowrap',
transition: 'all 0.15s ease',
}}
onMouseEnter={e => {
if (!isRestoring) e.currentTarget.style.background = '#15803d'
}}
onMouseLeave={e => {
if (!isRestoring) e.currentTarget.style.background = '#16a34a'
}}
>
<span>↺</span>
<span>{isRestoring ? 'Restoring...' : 'Restore Record / استعادة'}</span>
</button>
)}
{onPermanentDelete && (
<button
type="button"
onClick={onPermanentDelete}
style={{
background: '#dc2626',
color: 'white',
border: 'none',
borderRadius: 6,
padding: '5px 12px',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer',
boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
display: 'flex',
alignItems: 'center',
gap: 5,
whiteSpace: 'nowrap',
transition: 'all 0.15s ease',
}}
onMouseEnter={e => {
e.currentTarget.style.background = '#b91c1c'
}}
onMouseLeave={e => {
e.currentTarget.style.background = '#dc2626'
}}
>
<span>🗑️</span>
<span>Permanent Delete / حذف نهائي</span>
</button>
)}
</div>
</div>
)
}
interface DeleteRecordCardProps {
onDelete: () => void
disabled?: boolean
title?: string
btnText?: string
}
export function DeleteRecordCard({ onDelete, disabled, title = 'Delete Request', btnText = 'Delete' }: DeleteRecordCardProps) {
return (
<div
style={{
background: '#ffffff',
borderRadius: 10,
padding: 14,
border: '1px solid #fee2e2',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
}}
>
<div style={{ fontSize: 13, fontWeight: 700, color: '#1e293b', marginBottom: 10 }}>
{title}
</div>
<button
type="button"
onClick={onDelete}
disabled={disabled}
style={{
width: '100%',
padding: '8px 12px',
borderRadius: 6,
border: 'none',
background: '#dc2626',
color: '#ffffff',
fontWeight: 700,
fontSize: 12,
cursor: disabled ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
boxShadow: '0 1px 3px rgba(220, 38, 38, 0.25)',
transition: 'all 0.15s ease',
}}
onMouseEnter={e => {
if (!disabled) e.currentTarget.style.background = '#b91c1c'
}}
onMouseLeave={e => {
if (!disabled) e.currentTarget.style.background = '#dc2626'
}}
>
<span>🗑️</span>
<span>{btnText}</span>
</button>
</div>
)
}
interface FormCardProps {
title: string
children: React.ReactNode
style?: React.CSSProperties
}
export function FormCard({ title, children, style }: FormCardProps) {
return (
<div
className="form-card"
style={{
background: '#ffffff',
borderRadius: 10,
border: '1px solid #e2e8f0',
padding: '16px 20px',
marginBottom: 16,
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.03)',
...style,
}}
>
<div
className="form-card-title"
style={{
fontSize: 14,
fontWeight: 700,
color: '#1e293b',
marginBottom: 14,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<span>{title}</span>
</div>
{children}
</div>
)
}
interface StatusSummaryCardProps {
title?: string
priority?: string
createdDate?: string
modifiedDate?: string
assignedTo?: string
status?: string
}
export function StatusSummaryCard({
title = 'Status Summary',
priority,
createdDate,
modifiedDate,
assignedTo,
status,
}: StatusSummaryCardProps) {
const formatDate = (d?: string) => {
if (!d) return '—'
try {
const dt = new Date(d)
if (isNaN(dt.getTime())) return d
return dt.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })
} catch {
return d
}
}
return (
<div
style={{
background: '#ffffff',
borderRadius: 10,
padding: 14,
border: '1px solid #e2e8f0',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
}}
>
<div style={{ fontSize: 13, fontWeight: 700, color: '#1e293b', marginBottom: 12 }}>
{title}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{priority && (
<div>
<div style={{ fontSize: 10, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.4px', marginBottom: 3 }}>
Priority
</div>
<div style={{ fontSize: 13, fontWeight: 700, color: priority === 'High' || priority === 'Urgent' ? '#dc2626' : '#1e293b' }}>
{priority}
</div>
</div>
)}
{status && (
<div>
<div style={{ fontSize: 10, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.4px', marginBottom: 3 }}>
Status
</div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#334155' }}>
{status}
</div>
</div>
)}
<div>
<div style={{ fontSize: 10, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.4px', marginBottom: 3 }}>
Assigned To
</div>
<div style={{ fontSize: 12, fontWeight: 600, color: assignedTo ? '#334155' : '#94a3b8', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{assignedTo || '—'}
</div>
</div>
{createdDate && (
<div>
<div style={{ fontSize: 10, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.4px', marginBottom: 2 }}>
Created
</div>
<div style={{ fontSize: 11, color: '#64748b' }}>
{formatDate(createdDate)}
</div>
</div>
)}
{modifiedDate && (
<div>
<div style={{ fontSize: 10, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.4px', marginBottom: 2 }}>
Last Modified
</div>
<div style={{ fontSize: 11, color: '#64748b' }}>
{formatDate(modifiedDate)}
</div>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,40 @@
// src/components/Header.tsx
import { colors } from './common'
type Page = 'dashboard' | 'incidents-list' | 'incidents-form' | 'organizations' | 'ideas-list' | 'ideas-form' | 'orders-list' | 'orders-form'
interface HeaderProps {
currentPage: Page
}
export default function Header({ currentPage }: HeaderProps) {
const pageTitle: Record<Page, string> = {
dashboard: 'Dashboard',
'incidents-list': 'All Incidents',
'incidents-form': 'Submit Incident',
'ideas-list': 'All Ideas',
'ideas-form': 'Submit Idea',
'orders-list': 'All Orders',
'orders-form': 'Submit Order',
organizations: 'Organizations'
}
const pageSubtitle: Record<Page, string> = {
dashboard: 'System overview and metrics',
'incidents-list': 'Track and manage all incidents',
'incidents-form': 'Report a new incident',
'ideas-list': 'Employee ideas and study requests',
'ideas-form': 'Submit your idea or suggestion',
'orders-list': 'Track outsourcing and procurement orders',
'orders-form': 'Submit a new order request',
organizations: 'Manage registered organizations'
}
return (
<div style={{ background: 'white', padding: '20px 32px', borderBottom: `1px solid ${colors.gray}`, boxShadow: '0 1px 4px rgba(0,0,0,0.05)' }}>
<div style={{ fontSize: 22, fontWeight: 700, color: colors.dark }}>{pageTitle[currentPage]}</div>
<div style={{ fontSize: 13, color: '#6b7280', marginTop: 4 }}>{pageSubtitle[currentPage]}</div>
</div>
)
}

View File

@ -0,0 +1,99 @@
// src/components/Sidebar.tsx - FIXED
import { User } from '../types'
import { colors } from './common'
type Page = 'dashboard' | 'incidents-list' | 'incidents-form'
interface SidebarProps {
user: User
currentPage: Page
onPageChange: (page: Page) => void
onLogout: () => void
}
export default function Sidebar({ user, currentPage, onPageChange, onLogout }: SidebarProps) {
const navItems: Array<{ id: Page; icon: string; label: string }> = [
{ id: 'dashboard', icon: '📊', label: 'Dashboard' },
{ id: 'incidents-list', icon: '📋', label: 'Incidents' },
{ id: 'incidents-form', icon: '📝', label: 'Submit Incident' }
]
return (
<div style={{ width: 280, background: colors.sidebar, color: 'white', padding: '20px', display: 'flex', flexDirection: 'column', height: '100vh', boxSizing: 'border-box', overflow: 'hidden' }}>
{/* Logo */}
<div style={{ marginBottom: 32, textAlign: 'center', paddingBottom: 20, borderBottom: `1px solid ${colors.gray}` }}>
<div style={{ fontSize: 32, marginBottom: 8 }}>🏢</div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'white' }}>Sidrah</div>
</div>
{/* User Info */}
<div style={{ marginBottom: 24, padding: '12px', background: 'rgba(255,255,255,0.1)', borderRadius: 8 }}>
<div style={{ fontSize: 12, color: '#d1d5db', marginBottom: 4 }}>Logged in as</div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'white' }}>{user.full_name}</div>
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 4 }}>Role: {user.role}</div>
</div>
{/* Navigation */}
<nav style={{ flex: 1, overflowY: 'auto', marginBottom: 16 }}>
{navItems.map(item => (
<button
key={item.id}
onClick={() => onPageChange(item.id)}
style={{
width: '100%',
padding: '12px 16px',
marginBottom: 8,
background: currentPage === item.id ? colors.primary : 'transparent',
border: 'none',
borderRadius: 8,
color: 'white',
cursor: 'pointer',
textAlign: 'left',
fontSize: 14,
fontWeight: currentPage === item.id ? 600 : 400,
transition: 'all 0.2s',
display: 'flex',
alignItems: 'center',
gap: 12
}}
onMouseEnter={e => {
if (currentPage !== item.id) {
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(255,255,255,0.1)'
}
}}
onMouseLeave={e => {
if (currentPage !== item.id) {
(e.currentTarget as HTMLButtonElement).style.background = 'transparent'
}
}}
>
<span style={{ fontSize: 16 }}>{item.icon}</span>
<span>{item.label}</span>
</button>
))}
</nav>
{/* Logout Button */}
<button
onClick={onLogout}
style={{
width: '100%',
padding: '12px',
background: colors.red,
border: 'none',
borderRadius: 8,
color: 'white',
cursor: 'pointer',
fontSize: 14,
fontWeight: 600,
transition: 'all 0.2s'
}}
onMouseEnter={e => (e.currentTarget as HTMLButtonElement).style.opacity = '0.9'}
onMouseLeave={e => (e.currentTarget as HTMLButtonElement).style.opacity = '1'}
>
🚪 Logout
</button>
</div>
)
}

View File

@ -0,0 +1,253 @@
export const API_BASE_URL = ''
export const DOCTYPES = {
incidents: 'SB Incident',
ideas: 'SB Idea',
orders: 'SB Order',
organizations: 'SB Organization',
processes: 'SB Process',
managements: 'Managements',
departments: 'Department',
branches: 'Branch',
incidentTypes: 'Incident Types',
ideaTypes: 'Idea Types',
orderTypes: 'Order Types',
processTypes: 'Process Types',
incidentClass: 'Incident Class',
incidentField: 'Incident Field',
incidentStatuses: 'Incident Statuses',
ideaStatuses: 'Idea Statuses',
orderStatuses: 'Order Statuses',
processStatuses: 'Process Statuses',
users: 'User',
cycles: 'SB Cycle',
kpiInput: 'SB KPI Input',
delegations: 'SB Delegation',
groupCompanies: 'SB Group Company',
currency: 'Currency',
}
export const colors = {
bg: '#f5f6fa',
sidebar: '#1e1b4b',
accent: '#4f46e5',
red: '#ef4444',
orange: '#f59e0b',
green: '#16a34a',
purple: '#7c3aed',
blue: '#2563eb',
teal: '#0d9488',
gray: '#94a3b8'
}
export const NAV_ITEMS = [
{ id: 'dashboard', icon: '📊', label: 'Dashboard' },
{ id: 'inbox', icon: '📥', label: 'Inbox' },
{ id: 'incidents', icon: '📋', label: 'Incidents' },
{ id: 'ideas', icon: '💡', label: 'Ideas' },
{ id: 'orders', icon: '📦', label: 'Orders' },
{ id: 'processes', icon: '⚙️', label: 'Processes' },
{ id: 'delegations', icon: '🤝', label: 'Delegations' },
{ id: 'reports', icon: '📈', label: 'Reports' },
] as const
export const SETTINGS_NAV = [
{ id: 'group-companies', icon: '🌐', label: 'Group Companies' },
{ id: 'users', icon: '👤', label: 'Users' },
{ id: 'organizations', icon: '🏢', label: 'Organizations' },
{ id: 'managements', icon: '🏗️', label: 'Managements' },
{ id: 'departments', icon: '🗂️', label: 'Departments' },
{ id: 'branches', icon: '🌿', label: 'Branches' },
{ id: 'cycles', icon: '🔄', label: 'Cycles' },
{ id: 'kpi-input', icon: '📊', label: 'KPI Input' },
{ id: 'incident-types', icon: '🚨', label: 'Incident Types' },
{ id: 'idea-types', icon: '💡', label: 'Idea Types' },
{ id: 'order-types', icon: '📦', label: 'Order Types' },
{ id: 'process-types', icon: '⚙️', label: 'Process Types' },
{ id: 'incident-statuses', icon: '🔴', label: 'Incident Statuses' },
{ id: 'idea-statuses', icon: '🟢', label: 'Idea Statuses' },
{ id: 'order-statuses', icon: '🟣', label: 'Order Statuses' },
{ id: 'process-statuses', icon: '🔵', label: 'Process Statuses' },
] as const
// The Users and Group Companies record lists are restricted to Admin.
export const ADMIN_ONLY_SETTINGS_PAGES = new Set(['users', 'group-companies'])
export function settingsNavForRole(role: string): typeof SETTINGS_NAV[number][] {
if (role === 'Business Owner' || role === 'Owner') {
return SETTINGS_NAV.filter(({ id }) => id === 'organizations' || id === 'departments' || id === 'users')
}
if (role !== 'Admin' && role !== 'Auditor' && role !== 'CEO') return []
return SETTINGS_NAV.filter(({ id }) => role === 'Admin' || role === 'CEO' || !ADMIN_ONLY_SETTINGS_PAGES.has(id))
}
// ── List-page Export config ───────────────────────────────────────────────────
// Static per-doctype column definitions for the "Export" button on every list page.
// `orgField` names the field on each row holding its organization, so the modal can
// offer an org filter — omitted for lookup tables that aren't organization-scoped.
export interface ListExportConfig {
label: string
columns: { key: string; label: string }[]
defaultColumns: string[]
orgField?: string
}
const lookupColumns = (extra: { key: string; label: string }[] = []) => [
{ key: 'name1', label: 'Name' },
{ key: 'status', label: 'Status' },
...extra,
{ key: 'creation_date', label: 'Created On' },
]
export const LIST_EXPORT_CONFIGS: Record<string, ListExportConfig> = {
incidents: {
label: 'Incidents',
orgField: 'organization',
columns: [
{ key: 'number', label: 'Number' }, { key: 'title', label: 'Title' }, { key: 'organization', label: 'Organization' },
{ key: 'type', label: 'Type' }, { key: 'status', label: 'Status' }, { key: 'management', label: 'Management' },
{ key: 'department', label: 'Department' }, { key: 'branch', label: 'Branch' }, { key: 'date', label: 'Date' },
{ key: 'assigned_to', label: 'Assigned To' }, { key: 'priority', label: 'Priority' }, { key: 'economy_class', label: 'Economy Class' },
{ key: 'entity_scope', label: 'Entity Scope' }, { key: 'hses_field', label: 'HSES Field' }, { key: 'workflow_state', label: 'Workflow State' },
],
defaultColumns: ['number', 'title', 'organization', 'type', 'status', 'date', 'assigned_to'],
},
ideas: {
label: 'Ideas',
orgField: 'organization',
columns: [
{ key: 'number', label: 'Number' }, { key: 'title', label: 'Title' }, { key: 'organization', label: 'Organization' },
{ key: 'type', label: 'Type' }, { key: 'status', label: 'Status' }, { key: 'management', label: 'Management' },
{ key: 'department', label: 'Department' }, { key: 'branch', label: 'Branch' }, { key: 'date', label: 'Date' },
{ key: 'assigned_to', label: 'Assigned To' }, { key: 'priority', label: 'Priority' }, { key: 'economy_class', label: 'Economy Class' },
{ key: 'current_condition', label: 'Current Condition' }, { key: 'proposed_condition', label: 'Proposed Condition' },
{ key: 'workflow_state', label: 'Workflow State' },
],
defaultColumns: ['number', 'title', 'organization', 'type', 'status', 'date', 'assigned_to'],
},
orders: {
label: 'Orders',
orgField: 'organization',
columns: [
{ key: 'number', label: 'Number' }, { key: 'title', label: 'Title' }, { key: 'organization', label: 'Organization' },
{ key: 'type', label: 'Type' }, { key: 'status', label: 'Status' }, { key: 'management', label: 'Management' },
{ key: 'department', label: 'Department' }, { key: 'branch', label: 'Branch' }, { key: 'starting_date', label: 'Start Date' },
{ key: 'ending_date', label: 'End Date' }, { key: 'assigned_to', label: 'Assigned To' }, { key: 'budget', label: 'Budget' },
{ key: 'actual_cost', label: 'Actual Cost' }, { key: 'currency', label: 'Currency' }, { key: 'stage', label: 'Stage' },
{ key: 'completion_pct', label: 'Completion %' }, { key: 'priority', label: 'Priority' }, { key: 'workflow_state', label: 'Workflow State' },
],
defaultColumns: ['number', 'title', 'organization', 'type', 'status', 'starting_date', 'ending_date', 'assigned_to', 'budget'],
},
processes: {
label: 'Processes',
orgField: 'organization',
columns: [
{ key: 'number', label: 'Number' }, { key: 'title', label: 'Title' }, { key: 'organization', label: 'Organization' },
{ key: 'type', label: 'Type' }, { key: 'status', label: 'Status' }, { key: 'management', label: 'Management' },
{ key: 'department', label: 'Department' }, { key: 'branch', label: 'Branch' }, { key: 'date_start', label: 'Start Date' },
{ key: 'date_end', label: 'End Date' }, { key: 'actor', label: 'Actor' }, { key: 'cycle_type', label: 'Cycle Type' },
{ key: 'kpi_name', label: 'KPI Name' }, { key: 'cost_per_cycle', label: 'Cost / Cycle' }, { key: 'escalation_level', label: 'Escalation' },
{ key: 'workflow_state', label: 'Workflow State' },
],
defaultColumns: ['number', 'title', 'organization', 'type', 'status', 'date_start', 'date_end', 'actor'],
},
organizations: {
label: 'Organizations',
columns: [
{ key: 'name', label: 'Code' }, { key: 'organization_name', label: 'Organization Name' }, { key: 'type', label: 'Type' },
{ key: 'city', label: 'City' }, { key: 'contact_email', label: 'Contact Email' }, { key: 'is_active', label: 'Active' },
{ key: 'creation_date', label: 'Created On' },
],
defaultColumns: ['organization_name', 'type', 'city', 'contact_email', 'is_active'],
},
managements: {
label: 'Managements',
orgField: 'organization',
columns: [
{ key: 'name1', label: 'Name' }, { key: 'organization', label: 'Organization' }, { key: 'status', label: 'Status' },
{ key: 'creation_date', label: 'Created On' },
],
defaultColumns: ['name1', 'organization', 'status'],
},
departments: {
label: 'Departments',
orgField: 'custom_organization',
columns: [
{ key: 'department_name', label: 'Department Name' }, { key: 'custom_organization', label: 'Organization' },
{ key: 'custom_management', label: 'Management' }, { key: 'custom_status', label: 'Status' },
{ key: 'custom_creation_date', label: 'Created On' },
],
defaultColumns: ['department_name', 'custom_organization', 'custom_management', 'custom_status'],
},
branches: {
label: 'Branches',
orgField: 'organization',
columns: [
{ key: 'branch', label: 'Branch' }, { key: 'organization', label: 'Organization' }, { key: 'management', label: 'Management' },
{ key: 'department', label: 'Department' }, { key: 'custom_status', label: 'Status' }, { key: 'custom_creation_date', label: 'Created On' },
],
defaultColumns: ['branch', 'organization', 'management', 'department', 'custom_status'],
},
cycles: {
label: 'Cycles',
orgField: 'organization',
columns: [
{ key: 'name1', label: 'Name' }, { key: 'organization', label: 'Organization' }, { key: 'frequency', label: 'Frequency' },
{ key: 'status', label: 'Status' }, { key: 'start_date', label: 'Start Date' }, { key: 'end_date', label: 'End Date' },
{ key: 'update_window_days', label: 'Update Window (days)' },
],
defaultColumns: ['name1', 'organization', 'frequency', 'status', 'start_date', 'end_date'],
},
'kpi-input': {
label: 'KPI Input',
orgField: 'organization',
columns: [
{ key: 'organization', label: 'Organization' }, { key: 'period', label: 'Period' }, { key: 'revenue_growth', label: 'Revenue Growth' },
{ key: 'net_profit', label: 'Net Profit' }, { key: 'cash_flow', label: 'Cash Flow' }, { key: 'strategic_goal_pct', label: 'Strategic Goal %' },
{ key: 'customer_satisfaction', label: 'Customer Satisfaction' }, { key: 'employee_turnover_pct', label: 'Employee Turnover %' },
{ key: 'open_safety_incidents', label: 'Open Safety Incidents' }, { key: 'compliance_score', label: 'Compliance Score' },
],
defaultColumns: ['organization', 'period', 'revenue_growth', 'net_profit', 'cash_flow'],
},
'incident-types': { label: 'Incident Types', columns: lookupColumns(), defaultColumns: ['name1', 'status', 'creation_date'] },
'idea-types': { label: 'Idea Types', columns: lookupColumns(), defaultColumns: ['name1', 'status', 'creation_date'] },
'order-types': { label: 'Order Types', columns: lookupColumns(), defaultColumns: ['name1', 'status', 'creation_date'] },
'process-types': { label: 'Process Types', columns: lookupColumns(), defaultColumns: ['name1', 'status', 'creation_date'] },
'incident-statuses': { label: 'Incident Statuses', columns: lookupColumns([{ key: 'notify_manager', label: 'Notify Manager' }]), defaultColumns: ['name1', 'status', 'notify_manager'] },
'idea-statuses': { label: 'Idea Statuses', columns: lookupColumns([{ key: 'notify_manager', label: 'Notify Manager' }]), defaultColumns: ['name1', 'status', 'notify_manager'] },
'order-statuses': { label: 'Order Statuses', columns: lookupColumns([{ key: 'notify_manager', label: 'Notify Manager' }]), defaultColumns: ['name1', 'status', 'notify_manager'] },
'process-statuses': { label: 'Process Statuses', columns: lookupColumns([{ key: 'notify_manager', label: 'Notify Manager' }]), defaultColumns: ['name1', 'status', 'notify_manager'] },
users: {
label: 'Users',
orgField: 'custom_organization',
columns: [
{ key: 'full_name', label: 'Full Name' }, { key: 'email', label: 'Email' }, { key: 'username', label: 'Username' },
{ key: 'mobile_no', label: 'Phone' }, { key: 'custom_category', label: 'Category' }, { key: 'custom_organization', label: 'Organization' },
{ key: 'custom_management', label: 'Management' }, { key: 'custom_department', label: 'Department' }, { key: 'custom_branch', label: 'Branch' },
{ key: 'custom_number', label: 'User ID No' }, { key: 'enabled', label: 'Active' }, { key: 'creation', label: 'Created On' },
],
defaultColumns: ['full_name', 'email', 'custom_category', 'custom_organization', 'enabled'],
},
delegations: {
label: 'Delegations',
orgField: 'organization',
columns: [
{ key: 'number', label: 'Number' }, { key: 'auditor', label: 'Delegator' }, { key: 'manager', label: 'Manager' },
{ key: 'organization', label: 'Organization' }, { key: 'incident_initial_evaluation', label: 'Incident Initial Evaluation' },
{ key: 'idea_initial_evaluation', label: 'Idea Initial Evaluation' }, { key: 'status', label: 'Status' },
{ key: 'requested_on', label: 'Requested On' }, { key: 'responded_on', label: 'Responded On' },
],
defaultColumns: ['number', 'auditor', 'manager', 'organization', 'incident_initial_evaluation', 'idea_initial_evaluation', 'status', 'requested_on'],
},
'group-companies': {
label: 'Group Companies',
columns: [
{ key: 'company_name', label: 'Company Name' },
{ key: 'code', label: 'Code' },
{ key: 'acronym', label: 'Acronym' },
{ key: 'owner_user', label: 'Group Co Owner' },
{ key: 'status', label: 'Status' },
],
defaultColumns: ['company_name', 'code', 'acronym', 'owner_user', 'status'],
},
}

838
frontend/src/i18n.ts Normal file
View File

@ -0,0 +1,838 @@
import { useEffect, useState } from 'react'
const STORAGE_KEY = 'sidrah_rtl'
const CHANGE_EVENT = 'sidrah-lang-change'
export function isRTL(): boolean {
try { return localStorage.getItem(STORAGE_KEY) === '1' } catch { return false }
}
export function toggleLang(): void {
const next = !isRTL()
try { localStorage.setItem(STORAGE_KEY, next ? '1' : '0') } catch { }
document.documentElement.setAttribute('dir', next ? 'rtl' : 'ltr')
document.documentElement.setAttribute('lang', next ? 'ar' : 'en-GB')
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
export function useT() {
const [rtl, setRtl] = useState(isRTL)
useEffect(() => {
const handler = () => setRtl(isRTL())
window.addEventListener(CHANGE_EVENT, handler)
document.documentElement.setAttribute('dir', isRTL() ? 'rtl' : 'ltr')
document.documentElement.setAttribute('lang', isRTL() ? 'ar' : 'en-GB')
return () => window.removeEventListener(CHANGE_EVENT, handler)
}, [])
return rtl ? ar : en
}
// ─── English ───────────────────────────────────────────────────────────────
const en = {
rtl: false,
// Nav
nav: {
dashboard: 'Dashboard',
inbox: 'Inbox',
incidents: 'Incidents',
ideas: 'Ideas',
orders: 'Orders',
processes: 'Processes',
delegations: 'Delegations',
reports: 'Reports',
users: 'Users',
organizations: 'Organizations',
managements: 'Managements',
departments: 'Departments',
branches: 'Branches',
cycles: 'Cycles',
'kpi-input': 'KPI Input',
'incident-types': 'Incident Types',
'idea-types': 'Idea Types',
'order-types': 'Order Types',
'process-types': 'Process Types',
'incident-statuses': 'Incident Statuses',
'idea-statuses': 'Idea Statuses',
'order-statuses': 'Order Statuses',
'process-statuses': 'Process Statuses',
settings: 'Settings',
},
// Page titles
pages: {
dashboard: 'Dashboard',
inbox: 'Inbox',
reports: 'Reports',
incidents: 'Incidents',
ideas: 'Ideas',
orders: 'Orders',
processes: 'Processes',
delegations: 'Delegations',
organizations: 'Organizations',
managements: 'Managements',
departments: 'Departments',
branches: 'Branches',
cycles: 'Cycles',
'kpi-input': 'KPI Input',
'incident-types': 'Incident Types',
'idea-types': 'Idea Types',
'order-types': 'Order Types',
'process-types': 'Process Types',
'incident-statuses': 'Incident Statuses',
'idea-statuses': 'Idea Statuses',
'order-statuses': 'Order Statuses',
'process-statuses': 'Process Statuses',
users: 'Users',
},
// Common actions / buttons
new: '+ New',
save: 'Save',
cancel: 'Cancel',
edit: 'Edit',
delete: 'Delete',
deletedBadge: 'DELETED',
restore: 'Restore',
restoring: 'Restoring...',
restoredOk: 'Record restored successfully!',
deletedBanner: '⚠️ This record has been deleted',
deletedBy: 'Deleted by',
deletedOn: 'Deleted on',
deletedReason: 'Reason',
deleteModal: {
title: 'Confirm Deletion',
subtitle: 'Are you sure you want to delete this record?',
softDeleteTitle: 'Delete',
softDeleteDesc: 'Moves the record to deleted state. It can be restored later by an Administrator.',
permanentDeleteTitle: 'Permanent Delete',
permanentDeleteDesc: 'Permanently removes this record from the database. This action cannot be undone.',
reasonLabel: 'Reason for deletion (optional):',
reasonPlaceholder: 'Enter deletion reason…',
softDeleteBtn: 'Delete',
permanentDeleteBtn: 'Permanent Delete',
cancelBtn: 'Cancel',
deleting: 'Deleting…',
},
view: 'View',
back: '← Back',
clear: 'Clear',
search: 'Search',
loading: 'Loading...',
saving: 'Saving...',
savedOk: 'Saved successfully!',
home: 'Home',
noRecords: 'No records found',
// Login
login: {
subtitle: 'Sign in to continue',
username: 'USERNAME',
password: 'PASSWORD',
usernamePlaceholder: 'Enter username',
passwordPlaceholder: 'Enter password',
signIn: 'Sign In',
signingIn: 'Signing in...',
invalidCreds: 'Invalid username or password',
emptyFields: 'Please enter username and password',
connError: 'Connection error: ',
},
// UserMenu
myProfile: 'My Profile',
signOut: 'Sign Out',
switchLang: 'العربية (RTL)',
// Filter labels
filter: {
numberTitle: 'Number / Title',
searchPlaceholder: 'Search...',
organization: 'Organization',
department: 'Department',
branch: 'Branch',
status: 'Status',
allOrgs: 'All Organizations',
allDepts: 'All Departments',
allBranches: 'All Branches',
allStatuses: 'All Statuses',
allTypes: 'All Types',
},
// Table columns
col: {
number: 'Number',
title: 'Title',
organization: 'Organization',
type: 'Type',
status: 'Status',
actions: 'Actions',
name: 'Name',
department: 'Department',
branch: 'Branch',
city: 'City',
email: 'Email',
management: 'Management',
organization_type: 'Type',
frequency: 'Frequency',
period: 'Period',
startDate: 'Start Date',
endDate: 'End Date',
auditor: 'Auditor',
manager: 'Manager',
functions: 'Functions',
requestedOn: 'Requested On',
},
// Common form field labels
field: {
number: 'Number',
title: 'Title',
organization: 'Organization',
management: 'Management',
department: 'Department',
section: 'Section',
branch: 'Branch',
type: 'Type',
date: 'Date',
time: 'Time',
status: 'Status',
description: 'Description',
assignedTo: 'Assigned To',
hsesField: 'HSES Field',
priority: 'Priority',
economyClass: 'Economy Class',
entityScope: 'Entity Scope',
productRelation: 'Product Relation',
repeatedEvent: 'Repeated Event',
solution: 'Solution / Action Plan',
actualCost: 'Actual Cost',
summary: 'Resolution Summary',
auditorComment: 'Auditor Comment',
incidentClass: 'Incident Class',
incidentField: 'Incident Field',
currentCondition: 'Current Condition',
proposedCondition: 'Proposed Condition',
ideaClass: 'Idea Class',
startingDate: 'Starting Date',
endingDate: 'Ending Date',
budget: 'Budget',
currency: 'Currency',
notes: 'Notes',
cycle: 'Cycle',
stage: 'Stage',
completionPct: 'Completion %',
actor: 'Actor',
escalationLevel: 'Escalation Level',
dateStart: 'Start Date',
dateEnd: 'End Date',
cycleType: 'Cycle Type',
progressIndicators: 'Progress Indicators',
impactLevel: 'Impact Level',
kpiName: 'KPI Name',
kpiType: 'KPI Type',
kpiMeasure: 'KPI Measure',
costPerCycle: 'Cost per Cycle',
chainLocation: 'Chain Location',
notifyManager: 'Notify Manager',
creationDate: 'Creation Date',
statusActive: 'Active',
statusInactive: 'Inactive',
name1: 'Name',
fullName: 'Full Name',
username: 'Username',
emailAddress: 'Email Address',
phoneNumber: 'Phone Number',
category: 'Category',
since: 'Since',
active: 'Active',
inactive: 'Inactive',
enabled: 'Enabled',
selectMgr: '-- Select manager --',
select: '-- Select --',
eventCategory: 'Event Category',
selectType: '-- Select type --',
selectClass: '-- Select class --',
selectField: '-- Select field --',
selectStatus: '-- Select status --',
solutionPlaceholder: 'Describe the solution or action taken…',
commentPlaceholder: 'Optional comment…',
// Priority options
high: 'High',
normal: 'Normal',
// Economy class options
expenses: 'Expenses',
capital: 'Capital',
earn: 'Earn',
payLoss: 'Pay/Loss',
// Entity scope
upstream: 'Upstream',
onstream: 'On-stream',
downstream: 'Downstream',
// Org type
government: 'Government',
private: 'Private',
linkedOrder: 'Linked Order',
linkedIdea: 'Linked Idea',
convertedToOrder: 'Converted to Order',
originatingIdea: 'Originating Idea',
viewOrder: 'View Order',
viewIdea: 'View Idea',
// Frequency
monthly: 'Monthly',
quarterly: 'Quarterly',
yearly: 'Yearly',
// Cycle status
cycleActive: 'Active',
cycleCompleted: 'Completed',
cyclePaused: 'Paused',
updateWindowDays: 'Update Window (days)',
incidentTriageWindowDays: 'Triage Window (days)',
incidentResolutionWindowDays: 'Resolution Window (days)',
orgAcronym: 'Acronym',
contactEmail: 'Contact Email',
orgCity: 'City',
orgName: 'Organization Name',
organizationType: 'Organization Type',
revenue_growth: 'Revenue Growth (%)',
net_profit: 'Net Profit',
cash_flow: 'Cash Flow',
strategic_goal_pct: 'Strategic Goal (%)',
customer_satisfaction: 'Customer Satisfaction',
employee_turnover_pct: 'Employee Turnover (%)',
open_safety_incidents: 'Open Safety Incidents',
compliance_score: 'Compliance Score',
},
// Workflow panel
wf: {
workflow: 'Workflow',
noActions: 'No actions available for your role in this state.',
applying: 'Applying…',
// Actions
submit: 'Submit',
assignToManager: 'Assign to Manager',
reject: 'Reject',
submitSolution: 'Submit Solution',
resolutionComplete: 'Resolution complete',
moveToOutsourcing: 'Move to Outsourcing',
closeIdea: 'Close Idea',
},
// Step labels
steps: {
auditorTriage: 'Auditor Triage (Step 2)',
managerSolution: 'Manager Solution (Step 3)',
ownerComment: 'Owner / Auditor Comment (Step 4)',
auditorEval: 'Auditor Evaluation (Step 2)',
managerReview: 'Manager Review (Step 3)',
},
// Form page titles
formTitle: {
newIncident: 'New Incident',
editIncident: 'Edit Incident',
viewIncident: 'Incident: ',
newIdea: 'New Idea',
editIdea: 'Edit Idea',
viewIdea: 'Idea: ',
newOrder: 'New Order',
editOrder: 'Edit Order',
viewOrder: 'Order: ',
newProcess: 'New Process',
editProcess: 'Edit Process',
viewProcess: 'Process: ',
newOrg: 'New Organization',
editOrg: 'Edit Organization',
newMgmt: 'New Management',
editMgmt: 'Edit Management',
newDept: 'New Department',
editDept: 'Edit Department',
newBranch: 'New Branch',
editBranch: 'Edit Branch',
newUser: 'New User',
editUser: 'Edit User',
viewUser: 'User Profile',
newCycle: 'New Cycle',
editCycle: 'Edit Cycle',
newKpi: 'New KPI Input',
editKpi: 'Edit KPI Input',
newDelegation: 'New Delegation',
viewDelegation: 'Delegation: ',
},
// Button labels in form pages
saveIncident: 'Save Incident',
saveIdea: 'Save Idea',
saveOrder: 'Save Order',
saveProcess: 'Save Process',
saveOrg: 'Save Organization',
saveMgmt: 'Save Management',
saveDept: 'Save Department',
saveBranch: 'Save Branch',
saveUser: 'Save User',
saveCycle: 'Save Cycle',
saveKpi: 'Save KPI Input',
saveDelegation: 'Send Delegation',
saveName: 'Save',
// Dashboard
dash: {
allOrgs: 'All organizations',
incidents: 'Incidents',
ideas: 'Ideas',
orders: 'Orders',
processes: 'Processes',
level2: 'Level 2 — Critical Escalation',
level1: 'Level 1 — Auditor Review Needed',
overduePrefix: 'Overdue for Triage — ',
overdueSuffix: ' incident(s) past 3-day window',
untriaged: 'Untriaged Incidents',
recentKpi: 'Recent KPI Inputs',
manageKpi: 'Manage KPI Inputs',
escalatedProcesses: 'Escalated Processes',
escalatedOrders: 'Escalated Orders',
level: 'Level',
revenueGrowth: 'Revenue Growth',
netProfit: 'Net Profit',
strategicGoal: 'Strategic Goal',
customerSat: 'Customer Sat.',
compliance: 'Compliance',
safetyInc: 'Safety Incidents',
total: 'Total',
},
// No-data messages
noIncidents: 'No incidents found',
noIdeas: 'No ideas found',
noOrders: 'No orders found',
noProcesses: 'No processes found',
noUsers: 'No users found',
noData: 'No data found',
// Users page
users: {
category: 'Category',
number: 'Number',
fullName: 'Full Name',
username: 'Username',
email: 'Email Address',
phone: 'Phone Number',
organization: 'Organization',
management: 'Management',
department: 'Department',
branch: 'Branch',
since: 'Since',
status: 'Status',
tools: 'Tools',
active: 'Active',
inactive: 'Inactive',
setPassword: 'Set Password',
newPassword: 'New password',
passwordUpdated: 'Password updated!',
passwordFailed: 'Failed. Please try again.',
disableConfirm: 'Delete user? They will no longer appear in the list.',
password: 'Password',
},
// Inbox
inbox: {
title: 'Inbox',
noMessages: 'No messages',
},
// Reports
reports: {
title: 'Reports',
selectOrg: 'Select Organization',
generate: 'Generate Report',
},
// Comment section
comments: {
title: 'Comments',
placeholder: 'Write a comment…',
post: 'Post',
noComments: 'No comments yet.',
},
// Activity log
activity: {
title: 'Activity Log',
created: 'Created',
by: 'by',
},
}
// ─── Arabic ────────────────────────────────────────────────────────────────
const ar: typeof en = {
rtl: true,
nav: {
dashboard: 'لوحة التحكم',
inbox: 'البريد الوارد',
incidents: 'الحوادث',
ideas: 'الأفكار',
orders: 'الطلبات',
processes: 'العمليات',
delegations: 'التفويضات',
reports: 'التقارير',
users: 'المستخدمون',
organizations: 'المؤسسات',
managements: 'الإدارات',
departments: 'الأقسام',
branches: 'الفروع',
cycles: 'الدورات',
'kpi-input': 'مؤشرات الأداء',
'incident-types': 'أنواع الحوادث',
'idea-types': 'أنواع الأفكار',
'order-types': 'أنواع الطلبات',
'process-types': 'أنواع العمليات',
'incident-statuses': 'حالات الحوادث',
'idea-statuses': 'حالات الأفكار',
'order-statuses': 'حالات الطلبات',
'process-statuses': 'حالات العمليات',
settings: 'الإعدادات',
},
pages: {
dashboard: 'لوحة التحكم',
inbox: 'البريد الوارد',
reports: 'التقارير',
incidents: 'الحوادث',
ideas: 'الأفكار',
orders: 'الطلبات',
processes: 'العمليات',
delegations: 'التفويضات',
organizations: 'المؤسسات',
managements: 'الإدارات',
departments: 'الأقسام',
branches: 'الفروع',
cycles: 'الدورات',
'kpi-input': 'مؤشرات الأداء',
'incident-types': 'أنواع الحوادث',
'idea-types': 'أنواع الأفكار',
'order-types': 'أنواع الطلبات',
'process-types': 'أنواع العمليات',
'incident-statuses': 'حالات الحوادث',
'idea-statuses': 'حالات الأفكار',
'order-statuses': 'حالات الطلبات',
'process-statuses': 'حالات العمليات',
users: 'المستخدمون',
},
new: '+ جديد',
save: 'حفظ',
cancel: 'إلغاء',
edit: 'تعديل',
delete: 'حذف',
deletedBadge: 'محذوف',
restore: 'استعادة',
restoring: 'جارٍ الاستعادة...',
restoredOk: 'تمت استعادة السجل بنجاح!',
deletedBanner: '⚠️ هذا السجل محذوف',
deletedBy: 'تم الحذف بواسطة',
deletedOn: 'تاريخ الحذف',
deletedReason: 'السبب',
deleteModal: {
title: 'تأكيد الحذف',
subtitle: 'هل أنت متأكد من رغبتك في حذف هذا السجل؟',
softDeleteTitle: 'حذف',
softDeleteDesc: 'يتم نقل السجل إلى سلة المحذوفات. يمكن للمسؤول استعادته لاحقاً.',
permanentDeleteTitle: 'حذف نهائي',
permanentDeleteDesc: 'حذف السجل نهائياً من قاعدة البيانات. لا يمكن التراجع عن هذا الإجراء.',
reasonLabel: 'سبب الحذف (اختياري):',
reasonPlaceholder: 'أدخل سبب الحذف…',
softDeleteBtn: 'حذف',
permanentDeleteBtn: 'حذف نهائي',
cancelBtn: 'إلغاء',
deleting: 'جارٍ الحذف…',
},
view: 'عرض',
back: 'رجوع →',
clear: 'مسح',
search: 'بحث',
loading: 'جارٍ التحميل...',
saving: 'جارٍ الحفظ...',
savedOk: 'تم الحفظ بنجاح!',
home: 'الرئيسية',
noRecords: 'لا توجد سجلات',
login: {
subtitle: 'تسجيل الدخول للمتابعة',
username: 'اسم المستخدم',
password: 'كلمة المرور',
usernamePlaceholder: 'أدخل اسم المستخدم',
passwordPlaceholder: 'أدخل كلمة المرور',
signIn: 'تسجيل الدخول',
signingIn: 'جارٍ تسجيل الدخول...',
invalidCreds: 'اسم المستخدم أو كلمة المرور غير صحيحة',
emptyFields: 'الرجاء إدخال اسم المستخدم وكلمة المرور',
connError: 'خطأ في الاتصال: ',
},
myProfile: 'ملفي الشخصي',
signOut: 'تسجيل الخروج',
switchLang: 'English (LTR)',
filter: {
numberTitle: 'الرقم / العنوان',
searchPlaceholder: 'بحث...',
organization: 'المؤسسة',
department: 'القسم',
branch: 'الفرع',
status: 'الحالة',
allOrgs: 'جميع المؤسسات',
allDepts: 'جميع الأقسام',
allBranches: 'جميع الفروع',
allStatuses: 'جميع الحالات',
allTypes: 'جميع الأنواع',
},
col: {
number: 'الرقم',
title: 'العنوان',
organization: 'المؤسسة',
type: 'النوع',
status: 'الحالة',
actions: 'الإجراءات',
name: 'الاسم',
department: 'القسم',
branch: 'الفرع',
city: 'المدينة',
email: 'البريد الإلكتروني',
management: 'الإدارة',
organization_type: 'النوع',
frequency: 'التكرار',
period: 'الفترة',
startDate: 'تاريخ البدء',
endDate: 'تاريخ الانتهاء',
auditor: 'المدقق',
manager: 'المدير',
functions: 'الوظائف',
requestedOn: 'تاريخ الطلب',
},
field: {
number: 'الرقم',
title: 'العنوان',
organization: 'المؤسسة',
management: 'الإدارة',
department: 'القسم',
section: 'القطاع',
branch: 'الفرع',
type: 'النوع',
date: 'التاريخ',
time: 'الوقت',
status: 'الحالة',
description: 'الوصف',
assignedTo: 'مُسند إلى',
hsesField: 'مجال السلامة والصحة',
priority: 'الأولوية',
economyClass: 'التصنيف الاقتصادي',
entityScope: 'نطاق الكيان',
productRelation: 'علاقة المنتج',
repeatedEvent: 'حدث متكرر',
solution: 'الحل / خطة العمل',
actualCost: 'التكلفة الفعلية',
summary: 'ملخص الحل',
auditorComment: 'تعليق المدقق',
incidentClass: 'تصنيف الحادث',
incidentField: 'مجال الحادث',
currentCondition: 'الوضع الراهن',
proposedCondition: 'الوضع المقترح',
ideaClass: 'تصنيف الفكرة',
startingDate: 'تاريخ البدء',
endingDate: 'تاريخ الانتهاء',
budget: 'الميزانية',
currency: 'العملة',
notes: 'الملاحظات',
cycle: 'الدورة',
stage: 'المرحلة',
completionPct: 'نسبة الاكتمال',
actor: 'المنفذ',
escalationLevel: 'مستوى التصعيد',
dateStart: 'تاريخ البدء',
dateEnd: 'تاريخ الانتهاء',
cycleType: 'نوع الدورة',
progressIndicators: 'مؤشرات التقدم',
impactLevel: 'مستوى التأثير',
kpiName: 'اسم المؤشر',
kpiType: 'نوع المؤشر',
kpiMeasure: 'قياس المؤشر',
costPerCycle: 'التكلفة لكل دورة',
chainLocation: 'موقع السلسلة',
notifyManager: 'إشعار المدير',
creationDate: 'تاريخ الإنشاء',
statusActive: 'نشط',
statusInactive: 'غير نشط',
name1: 'الاسم',
fullName: 'الاسم الكامل',
username: 'اسم المستخدم',
emailAddress: 'البريد الإلكتروني',
phoneNumber: 'رقم الهاتف',
category: 'الفئة',
since: 'منذ',
active: 'نشط',
inactive: 'غير نشط',
enabled: 'مفعّل',
selectMgr: '-- اختر المدير --',
select: '-- اختر --',
eventCategory: 'فئة الحدث',
selectType: '-- اختر النوع --',
selectClass: '-- اختر التصنيف --',
selectField: '-- اختر المجال --',
selectStatus: '-- اختر الحالة --',
solutionPlaceholder: 'صف الحل أو الإجراء المتخذ…',
commentPlaceholder: 'تعليق اختياري…',
high: 'عالي',
normal: 'عادي',
expenses: 'مصروفات',
capital: 'رأس مال',
earn: 'إيرادات',
payLoss: 'دفع/خسارة',
upstream: 'مرحلة المنبع',
onstream: 'على الخط',
downstream: 'مرحلة المصب',
government: 'حكومي',
private: 'خاص',
linkedOrder: 'الأمر المرتبط',
linkedIdea: 'الفكرة المرتبطة',
convertedToOrder: 'تم التحويل إلى أمر',
originatingIdea: 'الفكرة الأصلية',
viewOrder: 'عرض الأمر',
viewIdea: 'عرض الفكرة',
monthly: 'شهري',
quarterly: 'ربع سنوي',
yearly: 'سنوي',
cycleActive: 'نشط',
cycleCompleted: 'مكتمل',
cyclePaused: 'موقوف',
updateWindowDays: 'نافذة التحديث (أيام)',
incidentTriageWindowDays: 'نافذة الفرز (أيام)',
incidentResolutionWindowDays: 'نافذة الحل (أيام)',
orgAcronym: 'الاختصار',
contactEmail: 'البريد الإلكتروني للتواصل',
orgCity: 'المدينة',
orgName: 'اسم المؤسسة',
organizationType: 'نوع المؤسسة',
revenue_growth: 'نمو الإيرادات (%)',
net_profit: 'صافي الربح',
cash_flow: 'التدفق النقدي',
strategic_goal_pct: 'الهدف الاستراتيجي (%)',
customer_satisfaction: 'رضا العملاء',
employee_turnover_pct: 'معدل دوران الموظفين (%)',
open_safety_incidents: 'حوادث السلامة المفتوحة',
compliance_score: 'درجة الامتثال',
},
wf: {
workflow: 'سير العمل',
noActions: 'لا توجد إجراءات متاحة لدورك في هذه الحالة.',
applying: 'جارٍ التطبيق...',
submit: 'إرسال',
assignToManager: 'تعيين للمدير',
reject: 'رفض',
submitSolution: 'إرسال الحل',
resolutionComplete: 'اكتمال الحل',
moveToOutsourcing: 'نقل للاستعانة بمصادر خارجية',
closeIdea: 'إغلاق الفكرة',
},
steps: {
auditorTriage: 'فرز المدقق (الخطوة 2)',
managerSolution: 'حل المدير (الخطوة 3)',
ownerComment: 'تعليق المالك / المدقق (الخطوة 4)',
auditorEval: 'تقييم المدقق (الخطوة 2)',
managerReview: 'مراجعة المدير (الخطوة 3)',
},
formTitle: {
newIncident: 'حادث جديد',
editIncident: 'تعديل الحادث',
viewIncident: 'الحادث: ',
newIdea: 'فكرة جديدة',
editIdea: 'تعديل الفكرة',
viewIdea: 'الفكرة: ',
newOrder: 'طلب جديد',
editOrder: 'تعديل الطلب',
viewOrder: 'الطلب: ',
newProcess: 'عملية جديدة',
editProcess: 'تعديل العملية',
viewProcess: 'العملية: ',
newOrg: 'مؤسسة جديدة',
editOrg: 'تعديل المؤسسة',
newMgmt: 'إدارة جديدة',
editMgmt: 'تعديل الإدارة',
newDept: 'قسم جديد',
editDept: 'تعديل القسم',
newBranch: 'فرع جديد',
editBranch: 'تعديل الفرع',
newUser: 'مستخدم جديد',
editUser: 'تعديل المستخدم',
viewUser: 'ملف المستخدم',
newCycle: 'دورة جديدة',
editCycle: 'تعديل الدورة',
newKpi: 'مؤشر أداء جديد',
editKpi: 'تعديل مؤشر الأداء',
newDelegation: 'تفويض جديد',
viewDelegation: 'التفويض: ',
},
saveIncident: 'حفظ الحادث',
saveIdea: 'حفظ الفكرة',
saveOrder: 'حفظ الطلب',
saveProcess: 'حفظ العملية',
saveOrg: 'حفظ المؤسسة',
saveMgmt: 'حفظ الإدارة',
saveDept: 'حفظ القسم',
saveBranch: 'حفظ الفرع',
saveUser: 'حفظ المستخدم',
saveCycle: 'حفظ الدورة',
saveKpi: 'حفظ مؤشر الأداء',
saveDelegation: 'إرسال التفويض',
saveName: 'حفظ',
dash: {
allOrgs: 'جميع المؤسسات',
incidents: 'الحوادث',
ideas: 'الأفكار',
orders: 'الطلبات',
processes: 'العمليات',
level2: 'المستوى 2 — تصعيد حرج',
level1: 'المستوى 1 — مراجعة المدقق مطلوبة',
overduePrefix: 'متأخر للفرز — ',
overdueSuffix: ' حادث(ة) تجاوز نافذة 3 أيام',
untriaged: 'حوادث غير مفرزة',
recentKpi: 'مؤشرات الأداء الأخيرة',
manageKpi: 'إدارة مؤشرات الأداء',
escalatedProcesses: 'عمليات متصاعدة',
escalatedOrders: 'طلبات متصاعدة',
level: 'المستوى',
revenueGrowth: 'نمو الإيرادات',
netProfit: 'صافي الربح',
strategicGoal: 'الهدف الاستراتيجي',
customerSat: 'رضا العملاء',
compliance: 'الامتثال',
safetyInc: 'حوادث السلامة',
total: 'الإجمالي',
},
noIncidents: 'لا توجد حوادث',
noIdeas: 'لا توجد أفكار',
noOrders: 'لا توجد طلبات',
noProcesses: 'لا توجد عمليات',
noUsers: 'لا يوجد مستخدمون',
noData: 'لا توجد بيانات',
users: {
category: 'الفئة',
number: 'الرقم',
fullName: 'الاسم الكامل',
username: 'اسم المستخدم',
email: 'البريد الإلكتروني',
phone: 'رقم الهاتف',
organization: 'المؤسسة',
management: 'الإدارة',
department: 'القسم',
branch: 'الفرع',
since: 'منذ',
status: 'الحالة',
tools: 'الأدوات',
active: 'نشط',
inactive: 'غير نشط',
setPassword: 'تعيين كلمة المرور',
newPassword: 'كلمة مرور جديدة',
passwordUpdated: 'تم تحديث كلمة المرور!',
passwordFailed: 'فشل. الرجاء المحاولة مرة أخرى.',
disableConfirm: 'حذف المستخدم؟ لن يظهر في القائمة بعد الآن.',
password: 'كلمة المرور',
},
inbox: {
title: 'البريد الوارد',
noMessages: 'لا توجد رسائل',
},
reports: {
title: 'التقارير',
selectOrg: 'اختر المؤسسة',
generate: 'إنشاء التقرير',
},
comments: {
title: 'التعليقات',
placeholder: 'اكتب تعليقاً…',
post: 'نشر',
noComments: 'لا توجد تعليقات بعد.',
},
activity: {
title: 'سجل النشاط',
created: 'أُنشئ',
by: 'بواسطة',
},
}
export type T = typeof en

792
frontend/src/index.css Normal file
View File

@ -0,0 +1,792 @@
*, *::before, *::after {
box-sizing: border-box;
}
:root {
/* Same base type as the HR app frontend */
font-family: ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji";
font-size: 14px;
line-height: 20px;
font-weight: 400;
color: #374151;
background-color: #f5f6fa;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
table {
font-family: inherit;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: #d3dbd7;
border-radius: 6px;
}
body {
margin: 0;
padding: 0;
min-height: 100vh;
width: 100%;
background-color: #f5f6fa;
color: #374151;
}
#root {
min-height: 100vh;
width: 100%;
display: flex;
flex-direction: column;
}
a {
color: #3498db;
text-decoration: none;
}
h1, h2, h3, h4, h5, h6 {
margin: 0;
color: #1a2332;
}
button {
font-family: inherit;
cursor: pointer;
}
input, select, textarea {
font-family: inherit;
font-size: 14px;
color: #333;
background-color: #fff;
}
input::placeholder, textarea::placeholder {
color: #aaa;
}
* {
outline-color: #4f46e5;
}
/* ── Dark mode ────────────────────────────────────────────────────────────── */
html.dark body {
background-color: #0b1329;
color: #e2e8f0;
}
html.dark input,
html.dark select,
html.dark textarea {
background-color: #1e293b !important;
color: #f1f5f9 !important;
border-color: #334155 !important;
}
html.dark select:disabled,
html.dark input:disabled,
html.dark textarea:disabled {
background-color: #151e2e !important;
color: #cbd5e1 !important;
border-color: #242f44 !important;
opacity: 1 !important;
cursor: default !important;
-webkit-text-fill-color: #cbd5e1 !important;
}
html.dark select option {
background-color: #1e293b !important;
color: #f1f5f9 !important;
}
html.dark input::placeholder,
html.dark textarea::placeholder {
color: #64748b !important;
}
/* ── Custom Select Component (OrgSelect, LinkSelect) ──────────────────────── */
.custom-select-container {
position: relative;
width: 100%;
}
.custom-select-trigger {
width: 100%;
height: 34px;
padding: 7px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
background-color: #ffffff;
color: #1e293b;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.15s ease;
user-select: none;
box-sizing: border-box;
}
.custom-select-trigger.small {
height: 28px;
padding: 4px 8px;
font-size: 11px;
}
.custom-select-trigger.is-placeholder .trigger-text {
color: #94a3b8;
}
.custom-select-trigger .trigger-arrow {
font-size: 9px;
color: #64748b;
transition: transform 0.2s ease;
}
.custom-select-trigger.open .trigger-arrow {
transform: rotate(180deg);
}
.custom-select-trigger.open {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.custom-select-trigger.disabled {
background-color: #f8fafc;
border-color: #e2e8f0;
color: #475569;
cursor: default;
opacity: 1 !important;
}
.custom-select-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 9999;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.custom-select-option {
padding: 8px 14px;
cursor: pointer;
font-size: 12px;
color: #1e293b;
transition: background-color 0.1s ease, color 0.1s ease;
}
.custom-select-option.small {
padding: 6px 10px;
font-size: 11px;
}
.custom-select-option.hovered {
background-color: #f1f5f9;
}
.custom-select-option.selected {
background-color: #2563eb;
color: #ffffff !important;
font-weight: 600;
}
.custom-select-option.placeholder-option {
color: #94a3b8;
}
.custom-select-option.inactive-option {
color: #ef4444;
}
.custom-select-empty {
padding: 12px 14px;
color: #94a3b8;
font-size: 12px;
text-align: center;
}
/* Dark mode for Custom Selects */
html.dark .custom-select-trigger {
background-color: #1e293b !important;
border-color: #334155 !important;
color: #f1f5f9 !important;
}
html.dark .custom-select-trigger .trigger-text {
color: #f1f5f9 !important;
}
html.dark .custom-select-trigger.is-placeholder .trigger-text {
color: #94a3b8 !important;
}
html.dark .custom-select-trigger .trigger-arrow {
color: #94a3b8 !important;
}
html.dark .custom-select-trigger.disabled {
background-color: #151e2e !important;
border-color: #242f44 !important;
color: #cbd5e1 !important;
opacity: 1 !important;
}
html.dark .custom-select-trigger.disabled .trigger-text {
color: #cbd5e1 !important;
}
html.dark .custom-select-trigger.open {
border-color: #3b82f6 !important;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25) !important;
}
html.dark .custom-select-dropdown {
background-color: #1e293b !important;
border-color: #334155 !important;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5) !important;
}
html.dark .custom-select-option {
color: #e2e8f0 !important;
}
html.dark .custom-select-option.hovered {
background-color: #334155 !important;
color: #ffffff !important;
}
html.dark .custom-select-option.selected {
background-color: #2563eb !important;
color: #ffffff !important;
}
html.dark .custom-select-option.placeholder-option {
color: #64748b !important;
}
html.dark .custom-select-empty {
color: #64748b !important;
}
/* Pages paint light surfaces and dark text via inline styles, which dark mode can't
re-render. Stylesheet !important beats inline styles, so this layer re-grounds the
common inline values (browsers serialize hex colors to rgb(), hence both forms). */
html.dark table {
color: #e2e8f0;
}
html.dark table thead tr,
html.dark table thead th {
background-color: #151e2e !important;
color: #9aa4bb !important;
border-color: #2c3548 !important;
}
html.dark table tbody tr {
background-color: #151e2e !important;
border-color: #242f44 !important;
}
html.dark table tbody tr:nth-child(even) {
background-color: #1a2538 !important;
}
html.dark table td {
border-color: #242f44 !important;
}
html.dark [style*="background: white"],
html.dark [style*="background: rgb(255, 255, 255)"],
html.dark [style*="background-color: white"],
html.dark [style*="background-color: rgb(255, 255, 255)"] {
background-color: #151e2e !important;
border-color: #242f44 !important;
}
html.dark [style*="background: rgb(250, 250, 250)"],
html.dark [style*="background: rgb(249, 249, 249)"],
html.dark [style*="background: rgb(249, 250, 251)"],
html.dark [style*="background: rgb(245, 246, 250)"],
html.dark [style*="background: rgb(248, 249, 250)"] {
background-color: #1a2436 !important;
border-color: #28354d !important;
}
/* Inline near-black text → light; inline mid-grey text → muted light */
html.dark [style*="color: rgb(85, 85, 85)"],
html.dark [style*="color: rgb(51, 51, 51)"],
html.dark [style*="color: rgb(26, 35, 50)"],
html.dark [style*="color: rgb(17, 24, 39)"],
html.dark [style*="color: rgb(55, 65, 81)"],
html.dark [style*="color: rgb(31, 41, 55)"],
html.dark [style*="color: rgb(30, 41, 59)"],
html.dark [style*="color: rgb(31, 36, 48)"],
html.dark [style*="color: rgb(51, 65, 85)"],
html.dark [style*="color: rgb(15, 23, 42)"],
html.dark [style*="color: rgb(71, 85, 105)"],
html.dark [style*="color: rgb(68, 68, 68)"],
html.dark [style*="color: rgb(30, 27, 75)"],
html.dark [style*="color: rgb(26, 35, 50)"] {
color: #e2e8f0 !important;
}
html.dark [style*="color: rgb(107, 114, 128)"],
html.dark [style*="color: rgb(136, 136, 136)"],
html.dark [style*="color: rgb(102, 102, 102)"],
html.dark [style*="color: rgb(100, 116, 139)"],
html.dark [style*="color: rgb(148, 148, 148)"],
html.dark [style*="color: rgb(107, 114, 128)"],
html.dark [style*="color: rgb(148, 163, 184)"],
html.dark [style*="color: rgb(153, 153, 153)"] {
color: #94a3b8 !important;
}
/* Form page labels always readable */
html.dark label {
color: #cbd5e1 !important;
}
/* Make read-only / disabled inputs readable in dark */
html.dark input:disabled,
html.dark select:disabled,
html.dark textarea:disabled,
html.dark [style*="background: rgb(248, 250, 252)"],
html.dark [style*="background-color: rgb(248, 250, 252)"],
html.dark [style*="background: #f8fafc"],
html.dark [style*="background-color: #f8fafc"],
html.dark [style*="background: rgb(248, 249, 250)"],
html.dark [style*="background: rgb(243, 244, 246)"],
html.dark [style*="background-color: rgb(243, 244, 246)"],
html.dark [style*="background: rgb(241, 245, 249)"] {
background-color: #1b2438 !important;
color: #cbd5e1 !important;
border-color: #2e3d55 !important;
}
/* FormCard & panel headings */
html.dark [style*="color: rgb(30, 41, 59)"],
html.dark [style*="color: rgb(51, 65, 85)"],
html.dark [style*="color: rgb(71, 85, 105)"],
html.dark [style*="color: #1e293b"],
html.dark [style*="color: #334155"] {
color: #f1f5f9 !important;
}
/* Delegation Function selection boxes */
.delegation-func-box {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px 14px;
background: #fafafa;
transition: all 0.15s ease;
}
.delegation-func-box.checked {
background: #f0fdf4;
border-color: #86efac;
}
.delegation-func-box .func-title {
color: #1e293b;
}
.delegation-func-box .func-sublabel {
color: #475569;
}
.delegation-func-box .func-hint {
color: #94a3b8;
}
html.dark .delegation-func-box {
border-color: #242f44 !important;
background: #1b2438 !important;
}
html.dark .delegation-func-box.checked {
background: #064e3b !important;
border-color: #059669 !important;
}
html.dark .delegation-func-box .func-title {
color: #f1f5f9 !important;
}
html.dark .delegation-func-box .func-sublabel {
color: #cbd5e1 !important;
}
html.dark .delegation-func-box .func-hint {
color: #94a3b8 !important;
}
/* Green pastel / delegation function checked state */
html.dark [style*="background: rgb(240, 253, 244)"],
html.dark [style*="background: #f0fdf4"],
html.dark [style*="background-color: rgb(240, 253, 244)"],
html.dark [style*="background-color: #f0fdf4"],
html.dark [style*="background: rgb(236, 253, 245)"],
html.dark [style*="background-color: rgb(236, 253, 245)"] {
background-color: #064e3b !important;
border-color: #059669 !important;
color: #ecfdf5 !important;
}
html.dark [style*="background: rgb(240, 253, 244)"] span,
html.dark [style*="background: #f0fdf4"] span,
html.dark [style*="background: rgb(240, 253, 244)"] label,
html.dark [style*="background: #f0fdf4"] label {
color: #ecfdf5 !important;
}
/* Yellow / Orange / Amber pastel */
html.dark [style*="background: rgb(255, 247, 237)"],
html.dark [style*="background: #fff7ed"],
html.dark [style*="background: rgb(254, 243, 199)"],
html.dark [style*="background: #fef3c7"],
html.dark [style*="background: rgb(254, 252, 232)"],
html.dark [style*="background: #fefce8"] {
background-color: #451a03 !important;
border-color: #b45309 !important;
color: #fef3c7 !important;
}
/* Red / Coral pastel */
html.dark [style*="background: rgb(254, 242, 242)"],
html.dark [style*="background: #fef2f2"],
html.dark [style*="background: rgb(254, 226, 226)"],
html.dark [style*="background: #fee2e2"] {
background-color: #450a0a !important;
border-color: #b91c1c !important;
color: #fecaca !important;
}
/* Cyan / Teal pastel */
html.dark [style*="background: rgb(224, 247, 250)"],
html.dark [style*="background: #e0f7fa"],
html.dark [style*="background: rgb(207, 250, 254)"],
html.dark [style*="background: #cffafe"] {
background-color: #164e63 !important;
border-color: #0891b2 !important;
color: #cffafe !important;
}
/* ── Form Cards ───────────────────────────────────────────────────────────── */
html.dark .form-card {
background-color: #151e2e !important;
border-color: #242f44 !important;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4) !important;
}
html.dark .form-card-title {
color: #f1f5f9 !important;
}
html.dark .form-card label {
color: #cbd5e1 !important;
}
html.dark .form-card input,
html.dark .form-card select,
html.dark .form-card textarea {
background-color: #1e293b !important;
color: #f1f5f9 !important;
border-color: #334155 !important;
}
html.dark .form-card input:disabled,
html.dark .form-card select:disabled,
html.dark .form-card textarea:disabled {
background-color: #1b2438 !important;
color: #cbd5e1 !important;
border-color: #2e3d55 !important;
}
.inbox-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
}
.inbox-card {
background: white;
border-radius: 14px;
padding: 16px 18px;
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.06), 0 1px 2px rgba(16, 24, 40, 0.04);
transition: box-shadow 0.18s ease, transform 0.18s ease;
}
.inbox-card:hover {
box-shadow: 0 10px 24px rgba(16, 24, 40, 0.10), 0 2px 6px rgba(16, 24, 40, 0.06);
transform: translateY(-2px);
}
.inbox-card-title {
color: #1f2430;
}
.inbox-card-meta {
color: #8a8fa3;
}
.inbox-empty {
background: white;
border-radius: 16px;
padding: 56px 24px;
text-align: center;
color: #9aa0b4;
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.06);
}
html.dark .inbox-card {
background: #1c2333;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
html.dark .inbox-card:hover {
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.4);
}
html.dark .inbox-card-title {
color: #e2e8f0;
}
html.dark .inbox-card-meta {
color: #8b93a8;
}
html.dark .inbox-empty {
background: #1c2333;
color: #7b8296;
}
/* ── Dashboard status cards ──────────────────────────────────────────────── */
.status-card {
background-color: white;
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.07), 0 1px 2px rgba(16, 24, 40, 0.04);
transition: box-shadow 0.18s ease, transform 0.18s ease;
}
.status-card:hover {
box-shadow: 0 12px 26px rgba(16, 24, 40, 0.10), 0 2px 6px rgba(16, 24, 40, 0.05);
transform: translateY(-2px);
}
.status-card-title {
color: #1e293b;
}
.status-card-label {
color: #64748b;
}
.status-card-count {
color: #334155;
}
.status-card-row:hover {
background: rgba(100, 116, 139, 0.08);
}
html.dark .status-card {
background-color: #1c2333;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
html.dark .status-card:hover {
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.45);
}
html.dark .status-card-title {
color: #e2e8f0;
}
html.dark .status-card-label {
color: #9aa4bb;
}
html.dark .status-card-count {
color: #dbe1ee;
}
html.dark .status-card-row:hover {
background: rgba(148, 163, 184, 0.12);
}
/* ── Modal Animations & Card Styles ───────────────────────────────────────── */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95) translateY(8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.delete-modal-card {
background: white;
transition: all 0.2s ease;
}
html.dark .delete-modal-card {
background-color: #1e293b !important;
border-color: #334155 !important;
color: #f1f5f9 !important;
}
html.dark .delete-modal-card h3 {
color: #f8fafc !important;
}
html.dark .delete-modal-card p,
html.dark .delete-modal-card label {
color: #94a3b8 !important;
}
html.dark .delete-modal-card input {
background-color: #0f172a !important;
border-color: #334155 !important;
color: #f8fafc !important;
}
/* ── Interactive Table Rows & Hover Highlight ────────────────────────────── */
.app-table-row {
transition: background-color 0.15s ease, box-shadow 0.15s ease;
cursor: pointer;
}
.app-table-row:hover {
background-color: #eff6ff !important;
}
html.dark .app-table-row:hover {
background-color: #243049 !important;
}
/* ── Modern Status & Type Badges ─────────────────────────────────────────── */
.status-badge {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.status-badge:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
/* ── Interactive Dashboard & KPI Hover Effects ───────────────────────────── */
.dashboard-kpi-card {
background: white;
border-radius: 12px;
padding: 16px;
border: 1px solid #f1f5f9;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
cursor: pointer;
}
.dashboard-kpi-card:hover {
transform: translateY(-3px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.08), 0 4px 10px -2px rgba(0, 0, 0, 0.04);
border-color: #cbd5e1;
}
html.dark .dashboard-kpi-card {
background-color: #1e293b;
border-color: #334155;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
html.dark .dashboard-kpi-card:hover {
border-color: #6366f1;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
}
/* ── Sidebar Navigation Item Hover Glow ──────────────────────────────────── */
.sidebar-nav-item {
user-select: none;
}
.sidebar-nav-item:active {
transform: scale(0.98);
}
/* ── Modern Dashboard Elements ───────────────────────────────────────────── */
.dashboard-quicklink {
background-color: white;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.dashboard-quicklink:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08) !important;
border-color: #cbd5e1 !important;
}
html.dark .dashboard-quicklink {
background-color: #1e293b !important;
border-color: #334155 !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3) !important;
}
html.dark .dashboard-quicklink span {
color: #f1f5f9 !important;
}
html.dark .dashboard-quicklink:hover {
background-color: #243049 !important;
border-color: #6366f1 !important;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5) !important;
}
.dashboard-alert-item {
transition: all 0.15s ease;
}
.dashboard-alert-item:hover {
transform: translateX(4px);
}
html.dark .dashboard-alert-item {
background-color: #151e2e !important;
border-color: #2e3d55 !important;
}
html.dark .dashboard-alert-item span {
color: #f1f5f9 !important;
}
.hero-quicklink-btn {
user-select: none;
}
.hero-quicklink-btn:hover {
background-color: rgba(255, 255, 255, 0.24) !important;
border-color: rgba(255, 255, 255, 0.45) !important;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25) !important;
}
.hero-quicklink-btn:active {
transform: scale(0.97);
}

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,135 @@
import { useT } from '../i18n'
import { Dispatch, SetStateAction } from 'react'
import { Branch, Management, Department, Organization, User, Page, SBGroupCompany } from '../types'
import { inp, lbl, activeItems, FormCard, DeletedBanner } from '../components/common'
import FormPageLayout from '../components/FormPageLayout'
import OrgSelect from '../components/OrgSelect'
import LinkSelect from '../components/LinkSelect'
import DateInput from '../components/DateInput'
import { DOCTYPES } from '../constants'
interface BranchFormPageProps {
form: Branch
setForm: Dispatch<SetStateAction<Branch>>
organizations: Organization[]
groupCompanies?: SBGroupCompany[]
managements: Management[]
departments: Department[]
formStatus: string
viewMode?: boolean
onEdit?: () => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
export default function BranchFormPage({
form, setForm, organizations, groupCompanies: _gc = [], managements, departments, formStatus, viewMode, onEdit, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile,
onSubmit, onBack, onNavigate, onLogout, user
}: BranchFormPageProps) {
const t = useT()
const set = (patch: Partial<Branch>) => setForm(f => ({ ...f, ...patch }))
const di: React.CSSProperties = viewMode ? { background: '#f8fafc' } : {}
const mgmtItems = activeItems(managements, form.management).map(m => ({ name: m.name, label: m.name1 || m.name }))
const deptItems = activeItems(departments, form.department).map(d => ({ name: d.name, label: d.department_name || d.name }))
const title = form.name
? (viewMode ? `${t.formTitle.editBranch}: ${form.branch || form.name}` : t.formTitle.editBranch)
: t.formTitle.newBranch
return (
<FormPageLayout
title={title} activePage="branches"
user={user} formStatus={formStatus}
buttonText={t.saveBranch} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{(form as any).custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={(form as any).custom_deleted_by}
deletedOn={(form as any).custom_deleted_on}
reason={(form as any).custom_deleted_reason}
onRestore={onRestore && form.name ? () => onRestore(form.name) : undefined}
onPermanentDelete={onPermanentDelete && form.name ? () => onPermanentDelete(form.name) : undefined}
/>
)}
<FormCard title="Branch Details">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
<div>
<label style={lbl}>{t.field.organization} <span style={{ color: '#e74c3c' }}>*</span></label>
<OrgSelect
value={form.organization}
onChange={v => {
const orgCo = organizations.find(o => o.name === v)?.group_company
set({ organization: v, ...(user.custom_group_company ? {} : { group_company: orgCo || form.group_company }) })
}}
organizations={organizations}
disabled={viewMode || !!user.custom_organization}
userRole={user.role}
/>
</div>
<div>
<label style={lbl}>{t.field.branch} <span style={{ color: '#e74c3c' }}>*</span></label>
<input
style={{ ...inp, ...di }}
value={form.branch}
disabled={viewMode || !!form.name}
onChange={e => set({ branch: e.target.value })}
placeholder="Branch name"
required
/>
</div>
<div>
<label style={lbl}>{t.field.management}</label>
<LinkSelect
value={form.management}
onChange={v => set({ management: v })}
items={mgmtItems}
placeholder={t.field.select}
disabled={viewMode}
userRole={user.role}
doctypeName={DOCTYPES.managements}
extraFields={{ organization: form.organization }}
/>
</div>
<div>
<label style={lbl}>{t.field.department}</label>
<LinkSelect
value={form.department}
onChange={v => set({ department: v })}
items={deptItems}
placeholder={t.field.select}
disabled={viewMode}
userRole={user.role}
doctypeName={DOCTYPES.departments}
extraFields={{ organization: form.organization, management: form.management }}
/>
</div>
<div>
<label style={lbl}>{t.field.creationDate}</label>
<DateInput value={form.custom_creation_date || ''} onChange={v => set({ custom_creation_date: v })} disabled={viewMode} style={{ ...inp, ...di }} />
</div>
<div>
<label style={lbl}>{t.field.status}</label>
<select style={{ ...inp, ...di }} value={form.custom_status || 'Active'} disabled={viewMode}
onChange={e => set({ custom_status: e.target.value })}>
<option value="Active">{t.field.statusActive}</option>
<option value="Inactive">{t.field.statusInactive}</option>
</select>
</div>
</div>
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,157 @@
import { useEffect, useMemo, useState } from 'react'
import { Branch, Management, Department } from '../types'
import { RowActions } from '../components/common'
import StatCard from '../components/StatCard'
import FilterBar from '../components/FilterBar'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { colors } from '../constants'
import { useT } from '../i18n'
interface BranchPageProps {
branches: Branch[]
managements: Management[]
departments: Department[]
canModify?: boolean
onView?: (b: Branch) => void
onEdit?: (b: Branch) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: Branch[], page: Branch[]) => void
}
function fmtDate(d?: string) {
if (!d) return '—'
const dt = new Date(d)
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')}/${dt.getFullYear()}`
}
export default function BranchPage({ branches: allBranches, managements, departments, canModify, onView, onEdit, onDelete, onSelectionChange }: BranchPageProps) {
const t = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [mgmtFilter, setMgmtFilter] = useState('')
const [deptFilter, setDeptFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const getMgmtLabel = (name: string) => managements.find(m => m.name === name)?.name1 || name || '—'
const getDeptLabel = (name: string) => departments.find(d => d.name === name)?.department_name || name || '—'
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }
const branches = useMemo(() => allBranches.filter(b => {
const label = (b.branch || b.name || '').toLowerCase()
if (search && !label.includes(search.toLowerCase())) return false
if (mgmtFilter && b.management !== mgmtFilter) return false
if (deptFilter && b.department !== deptFilter) return false
if (statusFilter !== 'all' && (b.custom_status || 'Active') !== statusFilter) return false
return true
}), [allBranches, search, mgmtFilter, deptFilter, statusFilter])
const [page, setPage] = useState(1)
useEffect(() => { setPage(1) }, [search, mgmtFilter, deptFilter, statusFilter])
const pageRows = branches.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = allBranches.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows])
return (
<>
<div style={{ display: 'flex', gap: 16 }}>
<StatCard label="Total" val={branches.length} color={colors.purple} />
</div>
<FilterBar filters={[
{ type: 'search', label: 'Search', value: search, onChange: setSearch, placeholder: 'Search by name…' },
{ type: 'select', label: t.col.management, value: mgmtFilter, onChange: setMgmtFilter, options: [
{ value: '', label: 'All Managements' }, ...managements.map(m => ({ value: m.name, label: m.name1 || m.name })),
] },
{ type: 'select', label: t.col.department, value: deptFilter, onChange: setDeptFilter, options: [
{ value: '', label: 'All Departments' }, ...departments.map(d => ({ value: d.name, label: d.department_name || d.name })),
] },
{ type: 'select', label: 'Status', value: statusFilter, onChange: setStatusFilter, options: [
{ value: 'all', label: 'All' }, { value: 'Active', label: t.field.statusActive }, { value: 'Inactive', label: t.field.statusInactive },
] },
]} />
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8f9fa', position: 'sticky', top: 0 }}>
<th style={{ width: 40, padding: '10px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={th}>{t.col.branch}</th>
<th style={th}>{t.col.management}</th>
<th style={th}>{t.col.department}</th>
<th style={th}>{t.field.creationDate}</th>
<th style={th}>{t.field.status}</th>
<th style={th}>{t.col.actions}</th>
</tr>
</thead>
<tbody>
{branches.length === 0
? <tr><td colSpan={7} style={{ padding: 40, textAlign: 'center', color: '#aaa' }}>{t.noData}</td></tr>
: pageRows.map((b, i) => (
<tr
key={b.name}
className="app-table-row"
onClick={() => onView?.(b)}
style={{
borderTop: '1px solid #f0f0f0',
background: selectedIds.has(b.name) ? '#eff6ff' : ((b as any).custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer'
}}
>
<td style={{ width: 40, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(b.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(b.name) ? next.delete(b.name) : next.add(b.name)
setSelectedIds(next)
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{b.branch || b.name || '—'}</td>
<td style={{ padding: '10px 14px' }}>{getMgmtLabel(b.management)}</td>
<td style={{ padding: '10px 14px' }}>{getDeptLabel(b.department)}</td>
<td style={{ padding: '10px 14px', color: '#666' }}>{fmtDate(b.custom_creation_date)}</td>
<td style={{ padding: '10px 14px' }}>
{(b as any).custom_is_deleted ? (
<span style={{ background: '#fee2e2', color: '#991b1b', padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 700 }}>
{t.deletedBadge || 'DELETED'}
</span>
) : (
<span style={{ color: b.custom_status === 'Inactive' ? '#e74c3c' : '#16a34a', fontWeight: 600 }}>
{b.custom_status === 'Inactive' ? t.field.statusInactive : t.field.statusActive}
</span>
)}
</td>
<td style={{ padding: '8px 14px' }} onClick={e => e.stopPropagation()}>
<RowActions onView={() => onView?.(b)} onEdit={canModify && onEdit && !(b as any).custom_is_deleted ? () => onEdit(b) : undefined} onDelete={canModify && onDelete && !(b as any).custom_is_deleted ? () => onDelete(b.name) : undefined} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={branches.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,132 @@
import { Dispatch, SetStateAction } from 'react'
import { useT } from '../i18n'
import { SBCycle, Organization, User, Page, SBGroupCompany } from '../types'
import { inp, lbl, FormCard, DeletedBanner } from '../components/common'
import FormPageLayout from '../components/FormPageLayout'
import OrgSelect from '../components/OrgSelect'
import LinkSelect from '../components/LinkSelect'
import DateInput from '../components/DateInput'
interface CycleFormPageProps {
form: SBCycle
setForm: Dispatch<SetStateAction<SBCycle>>
organizations: Organization[]
groupCompanies?: SBGroupCompany[]
formStatus: string
viewMode?: boolean
onEdit?: () => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
const FREQUENCY_OPTS = ['Weekly', 'Bi-weekly', 'Monthly', 'Quarterly', 'Annually']
const STATUS_OPTS = ['Active', 'Closed']
export default function CycleFormPage({
form, setForm, organizations, groupCompanies: _gc = [], formStatus, viewMode, onEdit, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile,
onSubmit, onBack, onNavigate, onLogout, user
}: CycleFormPageProps) {
const t = useT()
const set = (patch: Partial<SBCycle>) => setForm(f => ({ ...f, ...patch }))
const di: React.CSSProperties = viewMode ? { background: '#f8fafc' } : {}
const title = form.name
? (viewMode ? `${t.formTitle.editCycle}: ${form.name1 || form.name}` : t.formTitle.editCycle)
: t.formTitle.newCycle
return (
<FormPageLayout
title={title} activePage="cycles"
user={user} formStatus={formStatus}
buttonText={t.saveCycle} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{(form as any).custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={(form as any).custom_deleted_by}
deletedOn={(form as any).custom_deleted_on}
reason={(form as any).custom_deleted_reason}
onRestore={onRestore && form.name ? () => onRestore(form.name) : undefined}
onPermanentDelete={onPermanentDelete && form.name ? () => onPermanentDelete(form.name) : undefined}
/>
)}
<FormCard title="Cycle Details">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
<div>
<label style={lbl}>{t.field.organization} <span style={{ color: '#e74c3c' }}>*</span></label>
<OrgSelect
value={form.organization}
onChange={v => {
const orgCo = organizations.find(o => o.name === v)?.group_company
set({ organization: v, ...(user.custom_group_company ? {} : { group_company: orgCo || form.group_company }) })
}}
organizations={organizations}
disabled={viewMode || !!user.custom_organization}
userRole={user.role}
/>
</div>
<div>
<label style={lbl}>{t.field.name1} <span style={{ color: '#e74c3c' }}>*</span></label>
<input
style={{ ...inp, ...di }}
value={form.name1 || ''}
disabled={viewMode || !!form.name}
onChange={e => set({ name1: e.target.value })}
placeholder="e.g. Q1-2026"
/>
</div>
<div>
<label style={lbl}>{t.col.frequency}</label>
<LinkSelect
value={form.frequency}
onChange={v => set({ frequency: v })}
items={FREQUENCY_OPTS.map(o => ({ name: o, label: o }))}
placeholder="-- Select Frequency --"
disabled={viewMode}
/>
</div>
<div>
<label style={lbl}>{t.field.status}</label>
<LinkSelect
value={form.status}
onChange={v => set({ status: v })}
items={STATUS_OPTS.map(o => ({ name: o, label: o }))}
placeholder="-- Select Status --"
disabled={viewMode}
/>
</div>
<div>
<label style={lbl}>{t.field.dateStart}</label>
<DateInput value={form.start_date || ''} onChange={v => set({ start_date: v })} disabled={viewMode} style={{ ...inp, ...di }} />
</div>
<div>
<label style={lbl}>{t.field.dateEnd}</label>
<DateInput value={form.end_date || ''} onChange={v => set({ end_date: v })} disabled={viewMode} style={{ ...inp, ...di }} />
</div>
<div>
<label style={lbl}>{t.field.updateWindowDays}</label>
<input
type="number" min={1} max={30}
style={{ ...inp, ...di }}
value={form.update_window_days ?? 2}
disabled={viewMode}
onChange={e => set({ update_window_days: parseInt(e.target.value) || 2 })}
/>
<span style={{ fontSize: 11, color: '#64748b', display: 'block', marginTop: 4 }}>Grace period before Unknown is auto-assigned</span>
</div>
</div>
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,154 @@
import { useEffect, useMemo, useState } from 'react'
import { SBCycle } from '../types'
import { RowActions } from '../components/common'
import StatCard from '../components/StatCard'
import FilterBar from '../components/FilterBar'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { colors } from '../constants'
import { useT } from '../i18n'
interface CyclesPageProps {
cycles: SBCycle[]
canModify?: boolean
onView?: (c: SBCycle) => void
onEdit?: (c: SBCycle) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: SBCycle[], page: SBCycle[]) => void
}
const statusColor: Record<string, string> = {
Active: colors.green,
Closed: colors.gray,
}
export default function CyclesPage({ cycles: allCycles, canModify, onView, onEdit, onDelete, onSelectionChange }: CyclesPageProps) {
const t = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [orgFilter, setOrgFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const orgs = useMemo(() => [...new Set(allCycles.map(c => c.organization).filter(Boolean))], [allCycles])
const cycles = useMemo(() => allCycles.filter(c => {
const label = (c.name1 || c.name || '').toLowerCase()
if (search && !label.includes(search.toLowerCase())) return false
if (orgFilter && c.organization !== orgFilter) return false
if (statusFilter !== 'all' && c.status !== statusFilter) return false
return true
}), [allCycles, search, orgFilter, statusFilter])
const [page, setPage] = useState(1)
useEffect(() => { setPage(1) }, [search, orgFilter, statusFilter])
const pageRows = cycles.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = allCycles.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows])
const active = cycles.filter(c => c.status === 'Active').length
const closed = cycles.filter(c => c.status === 'Closed').length
return (
<>
<div style={{ display: 'flex', gap: 16 }}>
<StatCard label="Total" val={cycles.length} color={colors.teal} />
<StatCard label="Active" val={active} color={colors.green} />
<StatCard label="Closed" val={closed} color={colors.gray} />
</div>
<FilterBar filters={[
{ type: 'search', label: 'Search', value: search, onChange: setSearch, placeholder: 'Search by name…' },
{ type: 'select', label: t.col.organization, value: orgFilter, onChange: setOrgFilter, options: [
{ value: '', label: 'All Organizations' }, ...orgs.map(o => ({ value: o, label: o })),
] },
{ type: 'select', label: 'Status', value: statusFilter, onChange: setStatusFilter, options: [
{ value: 'all', label: 'All' }, { value: 'Active', label: 'Active' }, { value: 'Closed', label: 'Closed' },
] },
]} />
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8f9fa', position: 'sticky', top: 0 }}>
<th style={{ width: 40, padding: '10px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={{ padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }}>{t.col.name}</th>
<th style={{ padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }}>{t.col.organization}</th>
<th style={{ padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }}>{t.col.frequency}</th>
<th style={{ padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }}>{t.col.startDate}</th>
<th style={{ padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }}>{t.col.endDate}</th>
<th style={{ padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }}>{t.col.status}</th>
<th style={{ padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }}>{t.col.actions}</th>
</tr>
</thead>
<tbody>
{cycles.length === 0
? <tr><td colSpan={8} style={{ padding: 40, textAlign: 'center', color: '#aaa' }}>{t.noData}</td></tr>
: pageRows.map((c, i) => (
<tr
key={c.name}
className="app-table-row"
onClick={() => onView?.(c)}
style={{
borderTop: '1px solid #f0f0f0',
background: selectedIds.has(c.name) ? '#eff6ff' : ((c as any).custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer'
}}
>
<td style={{ width: 40, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(c.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(c.name) ? next.delete(c.name) : next.add(c.name)
setSelectedIds(next)
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{c.name1 || c.name}</td>
<td style={{ padding: '10px 14px' }}>{c.organization || '—'}</td>
<td style={{ padding: '10px 14px' }}>{c.frequency || '—'}</td>
<td style={{ padding: '10px 14px' }}>{c.start_date || '—'}</td>
<td style={{ padding: '10px 14px' }}>{c.end_date || '—'}</td>
<td style={{ padding: '10px 14px' }}>
{(c as any).custom_is_deleted ? (
<span style={{ background: '#fee2e2', color: '#991b1b', padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 700 }}>
{t.deletedBadge || 'DELETED'}
</span>
) : (
<span style={{ padding: '3px 10px', borderRadius: 12, fontSize: 12, background: statusColor[c.status] + '22', color: statusColor[c.status] || '#666' }}>
{c.status}
</span>
)}
</td>
<td style={{ padding: '8px 14px' }} onClick={e => e.stopPropagation()}>
<RowActions onView={() => onView?.(c)} onEdit={canModify && onEdit && !(c as any).custom_is_deleted ? () => onEdit(c) : undefined} onDelete={canModify && onDelete && !(c as any).custom_is_deleted ? () => onDelete(c.name) : undefined} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={cycles.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,685 @@
import { useEffect, useState, useMemo } from 'react'
import { Incident, Idea, Order, Process, Organization, IncidentStatus, IdeaStatus, OrderStatus, ProcessStatus, SBKpiInput, Page } from '../types'
import StatusCard from '../components/StatusCard'
import OrgSelect from '../components/OrgSelect'
import { getDashboardData } from '../api'
import { useT } from '../i18n'
interface DashboardPageProps {
incidents: Incident[]
ideas: Idea[]
orders: Order[]
processes: Process[]
organizations: Organization[]
incidentStatuses: IncidentStatus[]
ideaStatuses: IdeaStatus[]
orderStatuses: OrderStatus[]
processStatuses: ProcessStatus[]
selectedOrg: string
onOrgChange: (v: string) => void
onNavigate: (page: Page, name?: string) => void
onNavigateStatus: (page: Page, status: string) => void
userRole: string
}
// ── Shared sub-components ─────────────────────────────────────────────────────
function HeroQuickLink({ label, icon, onClick }: { label: string; icon: string; onClick: () => void }) {
return (
<button
onClick={onClick}
className="hero-quicklink-btn"
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '7px 14px',
background: 'rgba(255, 255, 255, 0.12)',
border: '1px solid rgba(255, 255, 255, 0.22)',
borderRadius: 10,
cursor: 'pointer',
backdropFilter: 'blur(8px)',
color: 'white',
fontSize: 13,
fontWeight: 700,
boxShadow: '0 2px 6px rgba(0,0,0,0.12)',
transition: 'all 0.18s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
<span style={{ fontSize: 15, display: 'flex', alignItems: 'center' }}>{icon}</span>
<span style={{ whiteSpace: 'nowrap' }}>{label}</span>
</button>
)
}
function EscalationList({ items, type, onNavigate }: { items: any[], type: string, onNavigate: (page: Page, name: string) => void }) {
const t = useT()
if (!items.length) return null
const page: Page = type === 'process' ? 'processes-form' : 'orders-form'
const label = type === 'process' ? t.dash.escalatedProcesses : t.dash.escalatedOrders
const isLevel2 = items.some(i => i.escalation_level === 2 || i.escalation_level === '2')
return (
<div>
<h4 style={{ fontWeight: 700, fontSize: 12, color: isLevel2 ? '#ef4444' : '#f59e0b', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{isLevel2 ? '🚨' : '⚠️'}</span>
<span>{label} ({items.length})</span>
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{items.map((item: any) => (
<div
key={item.name}
onClick={() => onNavigate(page, item.name)}
className="dashboard-alert-item"
style={{
padding: '10px 14px',
background: isLevel2 ? '#fef2f2' : '#fffbeb',
borderRadius: 10,
border: `1px solid ${isLevel2 ? '#fecaca' : '#fed7aa'}`,
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
transition: 'all 0.15s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ fontWeight: 700, fontSize: 12, color: isLevel2 ? '#991b1b' : '#92400e', whiteSpace: 'nowrap' }}>{item.number || item.name}</span>
<span style={{ fontSize: 12, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.title}</span>
</div>
<span style={{
fontSize: 10, fontWeight: 700,
color: isLevel2 ? '#b91c1c' : '#b45309',
background: isLevel2 ? '#fee2e2' : '#fef3c7',
border: `1px solid ${isLevel2 ? '#fca5a5' : '#fde68a'}`,
padding: '2px 8px', borderRadius: 9999, flexShrink: 0
}}>
{t.dash.level} {item.escalation_level}
</span>
</div>
))}
</div>
</div>
)
}
function KpiCard({ kpi }: { kpi: SBKpiInput }) {
const t = useT()
return (
<div className="dashboard-kpi-card" style={{
borderRadius: 14, padding: '16px 18px', border: '1px solid #e2e8f0',
background: 'white', boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
transition: 'all 0.2s ease', display: 'flex', flexDirection: 'column', gap: 12
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontWeight: 800, fontSize: 14, color: '#1e293b' }}>{kpi.organization}</div>
<span style={{ fontSize: 11, fontWeight: 700, color: '#4f46e5', background: '#eff6ff', border: '1px solid #c7d2fe', padding: '2px 9px', borderRadius: 9999 }}>
{kpi.period}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{[
{ label: t.dash.revenueGrowth, val: kpi.revenue_growth != null ? `${kpi.revenue_growth}%` : '—', color: '#10b981' },
{ label: t.dash.netProfit, val: kpi.net_profit != null ? kpi.net_profit.toLocaleString() : '—', color: '#3b82f6' },
{ label: t.dash.strategicGoal, val: kpi.strategic_goal_pct != null ? `${kpi.strategic_goal_pct}%` : '—', color: '#8b5cf6' },
{ label: t.dash.customerSat, val: kpi.customer_satisfaction != null ? kpi.customer_satisfaction : '—', color: '#f59e0b' },
{ label: t.dash.compliance, val: kpi.compliance_score != null ? kpi.compliance_score : '—', color: '#06b6d4' },
{ label: t.dash.safetyInc, val: kpi.open_safety_incidents != null ? kpi.open_safety_incidents : '—', color: '#ef4444' },
].map(({ label, val, color }) => (
<div key={label} style={{ background: '#f8fafc', padding: '8px 10px', borderRadius: 8, border: '1px solid #f1f5f9' }}>
<div style={{ fontSize: 10, color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.3px' }}>{label}</div>
<div style={{ fontSize: 14, fontWeight: 800, color: '#1e293b', marginTop: 2, display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: color }} />
{val}
</div>
</div>
))}
</div>
</div>
)
}
function TrendChart({ data }: { data: Array<{ month: string; pass: number; fail: number; unknown: number }> }) {
if (!data.length) return <p style={{ color: '#94a3b8', fontSize: 13 }}>No cycle data available for trend chart.</p>
const maxTotal = Math.max(...data.map(d => d.pass + d.fail + d.unknown), 1)
return (
<div>
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', overflowX: 'auto', paddingBottom: 8, paddingTop: 10 }}>
{data.map(d => {
const total = d.pass + d.fail + d.unknown || 1
const h = 100
return (
<div key={d.month} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, minWidth: 54 }}>
<div style={{ display: 'flex', flexDirection: 'column-reverse', gap: 2, height: h, width: 36, background: '#f1f5f9', borderRadius: 6, padding: 2, boxSizing: 'border-box' }}>
<div title={`Pass: ${d.pass}`} style={{ height: Math.round((d.pass / maxTotal) * (h - 4)), background: 'linear-gradient(180deg, #34d399 0%, #10b981 100%)', borderRadius: '4px 4px 0 0', minHeight: d.pass ? 4 : 0 }} />
<div title={`Fail: ${d.fail}`} style={{ height: Math.round((d.fail / maxTotal) * (h - 4)), background: 'linear-gradient(180deg, #f87171 0%, #ef4444 100%)', borderRadius: '4px 4px 0 0', minHeight: d.fail ? 4 : 0 }} />
<div title={`Unknown: ${d.unknown}`} style={{ height: Math.round((d.unknown / maxTotal) * (h - 4)), background: '#94a3b8', borderRadius: '4px 4px 0 0', minHeight: d.unknown ? 4 : 0 }} />
</div>
<div style={{ fontSize: 10, fontWeight: 600, color: '#64748b', textAlign: 'center', lineHeight: 1.2 }}>
{d.month.replace(/^(\d{4})-(\d{2})$/, (_, y, m) => {
const mn = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
return `${mn[+m - 1]} '${y.slice(2)}`
})}
</div>
<div style={{ fontSize: 10, fontWeight: 700, color: '#10b981', textAlign: 'center' }}>
{total > 1 ? Math.round(d.pass / total * 100) + '%' : '—'}
</div>
</div>
)
})}
</div>
<div style={{ display: 'flex', gap: 20, marginTop: 12, borderTop: '1px solid #f1f5f9', paddingTop: 10 }}>
{[['#10b981', 'Pass (Verified)'], ['#ef4444', 'Fail (Action Required)'], ['#94a3b8', 'Unknown / Unrecorded']].map(([color, label]) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 600 }}>
<div style={{ width: 10, height: 10, background: color as string, borderRadius: 3 }} />
<span style={{ color: '#64748b' }}>{label}</span>
</div>
))}
</div>
</div>
)
}
// ── Level 1 Dashboard — Operational View ──────────────────────────────────────
function Level1Dashboard({ dashData, fi, fid, fo, fp, incidentStatuses, ideaStatuses, orderStatuses, processStatuses, onNavigate, onNavigateStatus }: any) {
const t = useT()
const escalatedProc: any[] = dashData?.escalated_processes || []
const escalatedOrd: any[] = dashData?.escalated_orders || []
const attentionProc: any[] = dashData?.attention_processes || []
const attentionOrd: any[] = dashData?.attention_orders || []
const untriagedInc: any[] = dashData?.untriaged_incidents || []
const overdueTriageInc: any[] = dashData?.overdue_triage || []
const hasEscalations = escalatedProc.length > 0 || escalatedOrd.length > 0
const hasAttention = attentionProc.length > 0 || attentionOrd.length > 0
const incData: [string, number, string][] = [
['Total', fi.length, ''],
...incidentStatuses.map((s: any) => [s.name1 || s.name, fi.filter((i: any) => i.status === s.name).length, s.name] as [string, number, string])
]
const ideaData: [string, number, string][] = [
['Total', fid.length, ''],
...ideaStatuses.map((s: any) => [s.name1 || s.name, fid.filter((i: any) => i.status === s.name).length, s.name] as [string, number, string])
]
const ordData: [string, number, string][] = [
['Total', fo.length, ''],
...orderStatuses.map((s: any) => [s.name1 || s.name, fo.filter((o: any) => o.status === s.name).length, s.name] as [string, number, string])
]
const procData: [string, number, string][] = [
['Total', fp.length, ''],
...processStatuses.map((s: any) => [s.name1 || s.name, fp.filter((p: any) => p.status === s.name).length, s.name] as [string, number, string])
]
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Entity status cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 16 }}>
<div onClick={() => onNavigate('incidents')} style={{ cursor: 'pointer' }}>
<StatusCard title={t.dash.incidents} color="#ef4444" data={incData}
onSelect={status => onNavigateStatus('incidents', status)} />
</div>
<div onClick={() => onNavigate('ideas')} style={{ cursor: 'pointer' }}>
<StatusCard title={t.dash.ideas} color="#10b981" data={ideaData}
onSelect={status => onNavigateStatus('ideas', status)} />
</div>
<div onClick={() => onNavigate('orders')} style={{ cursor: 'pointer' }}>
<StatusCard title={t.dash.orders} color="#8b5cf6" data={ordData}
onSelect={status => onNavigateStatus('orders', status)} />
</div>
<div onClick={() => onNavigate('processes')} style={{ cursor: 'pointer' }}>
<StatusCard title={t.dash.processes} color="#3b82f6" data={procData}
onSelect={status => onNavigateStatus('processes', status)} />
</div>
</div>
{/* Escalation and Attention Panels */}
{(hasEscalations || hasAttention) && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
{hasEscalations && (
<div style={{
background: 'white', borderRadius: 14, padding: '16px 18px',
boxShadow: '0 2px 8px rgba(0,0,0,0.04)', border: '1px solid #fee2e2',
borderLeft: '5px solid #ef4444'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 style={{ fontWeight: 800, fontSize: 14, color: '#ef4444', margin: 0 }}>{t.dash.level2}</h3>
<span style={{ fontSize: 11, fontWeight: 700, background: '#fef2f2', color: '#ef4444', padding: '2px 8px', borderRadius: 9999 }}>
{escalatedProc.length + escalatedOrd.length} Critical
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<EscalationList items={escalatedProc} type="process" onNavigate={onNavigate} />
<EscalationList items={escalatedOrd} type="order" onNavigate={onNavigate} />
</div>
</div>
)}
{hasAttention && (
<div style={{
background: 'white', borderRadius: 14, padding: '16px 18px',
boxShadow: '0 2px 8px rgba(0,0,0,0.04)', border: '1px solid #fef3c7',
borderLeft: '5px solid #f59e0b'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 style={{ fontWeight: 800, fontSize: 14, color: '#b45309', margin: 0 }}>{t.dash.level1}</h3>
<span style={{ fontSize: 11, fontWeight: 700, background: '#fffbeb', color: '#b45309', padding: '2px 8px', borderRadius: 9999 }}>
{attentionProc.length + attentionOrd.length} Action Needed
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<EscalationList items={attentionProc} type="process" onNavigate={onNavigate} />
<EscalationList items={attentionOrd} type="order" onNavigate={onNavigate} />
</div>
</div>
)}
</div>
)}
{/* Incident Triage Warnings */}
{(overdueTriageInc.length > 0 || untriagedInc.length > 0) && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
{overdueTriageInc.length > 0 && (
<div style={{
background: 'white', borderRadius: 14, padding: '16px 18px',
boxShadow: '0 2px 8px rgba(0,0,0,0.04)', border: '1px solid #fee2e2',
borderLeft: '5px solid #dc2626'
}}>
<h3 style={{ fontWeight: 800, fontSize: 13, color: '#dc2626', marginBottom: 10 }}>
{t.dash.overduePrefix}{overdueTriageInc.length}{t.dash.overdueSuffix}
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{overdueTriageInc.map((inc: any) => (
<div key={inc.name} onClick={() => onNavigate('incidents-form', inc.name)}
style={{ padding: '8px 12px', background: '#fef2f2', borderRadius: 8, border: '1px solid #fca5a5', cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 700, fontSize: 12, color: '#991b1b' }}>{inc.number || inc.name}</span>
<span style={{ fontSize: 12, color: '#475569' }}>{inc.title}</span>
<span style={{ fontSize: 11, color: '#94a3b8' }}>{inc.date}</span>
</div>
))}
</div>
</div>
)}
{untriagedInc.length > 0 && (
<div style={{
background: 'white', borderRadius: 14, padding: '16px 18px',
boxShadow: '0 2px 8px rgba(0,0,0,0.04)', border: '1px solid #ffedd5',
borderLeft: '5px solid #f97316'
}}>
<h3 style={{ fontWeight: 800, fontSize: 13, color: '#ea580c', marginBottom: 10 }}>
{t.dash.untriaged} ({untriagedInc.length})
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{untriagedInc.map((inc: any) => (
<div key={inc.name} onClick={() => onNavigate('incidents-form', inc.name)}
style={{ padding: '8px 12px', background: '#fff7ed', borderRadius: 8, border: '1px solid #fed7aa', cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 700, fontSize: 12, color: '#9a3412' }}>{inc.number || inc.name}</span>
<span style={{ fontSize: 12, color: '#475569' }}>{inc.title}</span>
<span style={{ fontSize: 11, color: '#94a3b8' }}>{inc.date}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}
// ── Level 2 Dashboard — Executive View ────────────────────────────────────────
function Level2Dashboard({ dashData, selectedOrg, onNavigate }: any) {
const t = useT()
const [trendData, setTrendData] = useState<any[]>([])
const [activityLog, setActivityLog] = useState<any[]>([])
const [loadingTrend, setLoadingTrend] = useState(false)
const escalatedProc: any[] = dashData?.escalated_processes || []
const escalatedOrd: any[] = dashData?.escalated_orders || []
const attentionProc: any[] = dashData?.attention_processes || []
const attentionOrd: any[] = dashData?.attention_orders || []
const kpiInputs: SBKpiInput[] = dashData?.kpi_inputs || []
useEffect(() => {
setLoadingTrend(true)
const params = new URLSearchParams({ months: '6' })
if (selectedOrg) params.append('organization', selectedOrg)
fetch(`/api/method/sidrah_business.api.get_process_trend?${params}`, {
credentials: 'include', headers: { Accept: 'application/json' }
})
.then(r => r.json())
.then(json => {
const raw: Array<{ month: string; status: string; count: number }> = json.message || []
const byMonth: Record<string, { pass: number; fail: number; unknown: number }> = {}
for (const row of raw) {
if (!byMonth[row.month]) byMonth[row.month] = { pass: 0, fail: 0, unknown: 0 }
if (row.status === 'Pass') byMonth[row.month].pass = row.count
else if (row.status === 'Fail') byMonth[row.month].fail = row.count
else if (row.status === 'Unknown') byMonth[row.month].unknown = row.count
}
setTrendData(Object.entries(byMonth).map(([month, v]) => ({ month, ...v })))
})
.catch(() => {})
.finally(() => setLoadingTrend(false))
}, [selectedOrg])
useEffect(() => {
fetch('/api/method/sidrah_business.api.get_report_data', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Frappe-CSRF-Token': (window as any).csrf_token || '' },
body: JSON.stringify({ report_name: 'user_activity_log', filters_json: '{}' }),
})
.then(r => r.json())
.then(json => { setActivityLog((json.message?.rows || []).slice(0, 50)) })
.catch(() => {})
}, [selectedOrg])
const hasEscalations = escalatedProc.length > 0 || escalatedOrd.length > 0
const hasAttention = attentionProc.length > 0 || attentionOrd.length > 0
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Multi-org health overview */}
{kpiInputs.length > 0 && (
<div style={{ background: 'white', borderRadius: 14, padding: '18px 20px', boxShadow: '0 2px 8px rgba(0,0,0,0.04)', border: '1px solid #e2e8f0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<h3 style={{ fontWeight: 800, fontSize: 14, color: '#1e293b', margin: 0 }}>{t.dash.recentKpi}</h3>
<button
onClick={() => onNavigate('kpi-input')}
style={{ padding: '6px 14px', borderRadius: 8, border: '1px solid #cbd5e1', background: '#f8fafc', color: '#334155', fontWeight: 600, fontSize: 12, cursor: 'pointer' }}
>
{t.dash.manageKpi} →
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 14 }}>
{kpiInputs.map((k: SBKpiInput) => <KpiCard key={k.name} kpi={k} />)}
</div>
</div>
)}
{/* Escalation panels */}
{(hasEscalations || hasAttention) && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
{hasEscalations && (
<div style={{
background: 'white', borderRadius: 14, padding: '16px 18px',
boxShadow: '0 2px 8px rgba(0,0,0,0.04)', border: '1px solid #fee2e2',
borderLeft: '5px solid #ef4444'
}}>
<h3 style={{ fontWeight: 800, fontSize: 14, color: '#ef4444', marginBottom: 12 }}>
Critical Escalations — Level 2 ({escalatedProc.length + escalatedOrd.length})
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<EscalationList items={escalatedProc} type="process" onNavigate={onNavigate} />
<EscalationList items={escalatedOrd} type="order" onNavigate={onNavigate} />
</div>
</div>
)}
{hasAttention && (
<div style={{
background: 'white', borderRadius: 14, padding: '16px 18px',
boxShadow: '0 2px 8px rgba(0,0,0,0.04)', border: '1px solid #fef3c7',
borderLeft: '5px solid #f59e0b'
}}>
<h3 style={{ fontWeight: 800, fontSize: 14, color: '#b45309', marginBottom: 12 }}>
Needs Attention — Level 1 ({attentionProc.length + attentionOrd.length})
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<EscalationList items={attentionProc} type="process" onNavigate={onNavigate} />
<EscalationList items={attentionOrd} type="order" onNavigate={onNavigate} />
</div>
</div>
)}
</div>
)}
{/* Trend chart */}
<div style={{ background: 'white', borderRadius: 14, padding: '18px 20px', boxShadow: '0 2px 8px rgba(0,0,0,0.04)', border: '1px solid #e2e8f0' }}>
<h3 style={{ fontWeight: 800, fontSize: 14, color: '#1e293b', marginBottom: 4 }}>Process Execution Trend — Last 6 Months</h3>
<p style={{ fontSize: 12, color: '#64748b', marginBottom: 12 }}>Pass / Fail verification metrics across all processes and review cycles.</p>
{loadingTrend ? <p style={{ color: '#94a3b8', fontSize: 13 }}>Loading trend analytics…</p> : <TrendChart data={trendData} />}
</div>
{/* Log book table */}
{activityLog.length > 0 && (
<div style={{ background: 'white', borderRadius: 14, padding: '18px 20px', boxShadow: '0 2px 8px rgba(0,0,0,0.04)', border: '1px solid #e2e8f0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<h3 style={{ fontWeight: 800, fontSize: 14, color: '#1e293b', margin: 0 }}>Audit Log Book — Recent Activity</h3>
<button
onClick={() => onNavigate('reports')}
style={{ padding: '6px 14px', borderRadius: 8, border: '1px solid #cbd5e1', background: '#f8fafc', color: '#334155', fontWeight: 600, fontSize: 12, cursor: 'pointer' }}
>
Full Activity Log →
</button>
</div>
<div style={{ overflowX: 'auto', borderRadius: 10, border: '1px solid #f1f5f9' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8fafc' }}>
{['Timestamp', 'User', 'Entity Type', 'Entity', 'Fields Changed'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', color: '#64748b', fontWeight: 700, fontSize: 11, whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{activityLog.map((row: any, i: number) => (
<tr key={i} style={{ borderTop: '1px solid #f1f5f9', background: i % 2 === 0 ? 'white' : '#fafafa' }}>
<td style={{ padding: '10px 14px', color: '#64748b', whiteSpace: 'nowrap' }}>{row.timestamp ? String(row.timestamp).slice(0, 16) : '—'}</td>
<td style={{ padding: '10px 14px', fontWeight: 600, color: '#1e293b' }}>{row.user || '—'}</td>
<td style={{ padding: '10px 14px', color: '#475569' }}>
<span style={{ background: '#f1f5f9', padding: '2px 8px', borderRadius: 6, fontWeight: 600, fontSize: 11 }}>{row.entity_type || '—'}</span>
</td>
<td style={{ padding: '10px 14px', color: '#1e293b', fontWeight: 500 }}>{row.entity_name || '—'}</td>
<td style={{ padding: '10px 14px', color: '#64748b', maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{row.changes || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}
// ── Main Dashboard Component ──────────────────────────────────────────────────
export default function DashboardPage({
incidents, ideas, orders, processes, organizations, incidentStatuses, ideaStatuses,
orderStatuses, processStatuses, selectedOrg, onOrgChange, onNavigate, onNavigateStatus, userRole
}: DashboardPageProps) {
const [dashData, setDashData] = useState<any>(null)
const [loading, setLoading] = useState(false)
const loadData = () => {
setLoading(true)
getDashboardData(selectedOrg)
.then(setDashData)
.finally(() => setLoading(false))
}
useEffect(() => {
loadData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedOrg])
const fi = useMemo(() => selectedOrg ? incidents.filter(i => i.organization === selectedOrg) : incidents, [incidents, selectedOrg])
const fid = useMemo(() => selectedOrg ? ideas.filter(i => i.organization === selectedOrg) : ideas, [ideas, selectedOrg])
const fo = useMemo(() => selectedOrg ? orders.filter(o => o.organization === selectedOrg) : orders, [orders, selectedOrg])
const fp = useMemo(() => selectedOrg ? processes.filter(p => p.organization === selectedOrg) : processes, [processes, selectedOrg])
const isAdmin = userRole === 'Admin'
const isOwner = userRole === 'Owner' || userRole === 'Business Owner'
const isCEO = userRole === 'CEO' || userRole === 'Chairman'
const canSwitchLevel = isAdmin || isOwner || isCEO
const [activeDashboard, setActiveDashboard] = useState<'level1' | 'level2'>('level1')
const showLevel2 = activeDashboard === 'level2'
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Hero Header Banner */}
<div style={{
background: 'linear-gradient(135deg, #1e1b4b 0%, #312e81 45%, #1e3a8a 100%)',
borderRadius: 16,
padding: '22px 26px',
color: 'white',
boxShadow: '0 8px 30px rgba(30, 27, 75, 0.25)',
position: 'relative',
zIndex: 20,
}}>
{/* Glow accents wrapper with overflow hidden for clipped corners */}
<div style={{ position: 'absolute', inset: 0, borderRadius: 'inherit', overflow: 'hidden', pointerEvents: 'none' }}>
<div style={{ position: 'absolute', top: -50, right: -50, width: 220, height: 220, borderRadius: '50%', background: 'rgba(99, 102, 241, 0.25)', filter: 'blur(50px)' }} />
<div style={{ position: 'absolute', bottom: -40, left: '35%', width: 180, height: 180, borderRadius: '50%', background: 'rgba(59, 130, 246, 0.2)', filter: 'blur(45px)' }} />
</div>
<div style={{ position: 'relative', zIndex: 1 }}>
{/* Top Row: Title + Scope + Controls */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 16, marginBottom: 20 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<span style={{ fontSize: 24 }}>⚡</span>
<h2 style={{ fontSize: 22, fontWeight: 800, color: 'white', margin: 0, letterSpacing: -0.5 }}>
{showLevel2 ? 'Dashboard 2: Executive Performance' : 'Dashboard 1: Operational Activity'}
</h2>
</div>
<p style={{ fontSize: 13, color: '#c7d2fe', margin: 0, opacity: 0.9 }}>
{selectedOrg ? `Real-time analytics for ${selectedOrg}` : 'Enterprise overview across all organizations & business units'}
</p>
</div>
{/* Filter & Level Switcher Tools */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
{/* Org Selector */}
<div style={{ minWidth: 190, maxWidth: 260 }}>
<OrgSelect
value={selectedOrg}
onChange={onOrgChange}
organizations={organizations}
placeholder="All Organizations"
small
/>
</div>
{/* Level Switcher (Dashboard 1 & Dashboard 2) */}
{canSwitchLevel && (
<div style={{ display: 'flex', background: 'rgba(255, 255, 255, 0.12)', borderRadius: 10, padding: 3, gap: 3, backdropFilter: 'blur(8px)', border: '1px solid rgba(255, 255, 255, 0.15)' }}>
<button
onClick={() => setActiveDashboard('level1')}
style={{
padding: '5px 12px',
borderRadius: 7,
border: 'none',
fontWeight: 700,
fontSize: 11,
cursor: 'pointer',
background: activeDashboard === 'level1' ? 'white' : 'transparent',
color: activeDashboard === 'level1' ? '#1e1b4b' : '#c7d2fe',
boxShadow: activeDashboard === 'level1' ? '0 2px 6px rgba(0,0,0,0.15)' : 'none',
transition: 'all 0.15s ease',
}}
>
Dashboard 1 (Ops)
</button>
<button
onClick={() => setActiveDashboard('level2')}
style={{
padding: '5px 12px',
borderRadius: 7,
border: 'none',
fontWeight: 700,
fontSize: 11,
cursor: 'pointer',
background: activeDashboard === 'level2' ? '#8b5cf6' : 'transparent',
color: 'white',
boxShadow: activeDashboard === 'level2' ? '0 2px 6px rgba(0,0,0,0.2)' : 'none',
transition: 'all 0.15s ease',
}}
>
Dashboard 2 (Exec)
</button>
</div>
)}
{/* Role badge for non-switchers */}
{!canSwitchLevel && (
<div style={{
fontSize: 11, fontWeight: 700, color: 'white',
background: '#3b82f6',
padding: '5px 14px', borderRadius: 9999,
border: '1px solid rgba(255, 255, 255, 0.2)',
boxShadow: '0 2px 6px rgba(0,0,0,0.15)'
}}>
💼 Operations View
</div>
)}
{/* Refresh button */}
<button
onClick={loadData}
disabled={loading}
title="Refresh Metrics"
style={{
width: 32, height: 32, borderRadius: 10,
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
color: 'white', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, transition: 'all 0.15s',
}}
>
<span style={{ display: 'inline-block', transform: loading ? 'rotate(180deg)' : 'none', transition: 'transform 0.4s ease' }}>🔄</span>
</button>
</div>
</div>
{/* Bottom Row: Quick Navigation Links inside the Hero Banner */}
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
{showLevel2 ? (
<>
<HeroQuickLink label="Manage Users & Roles" icon="👥" onClick={() => onNavigate('users')} />
<HeroQuickLink label="Active Review Cycles" icon="🔄" onClick={() => onNavigate('cycles')} />
<HeroQuickLink label="Strategic KPI Inputs" icon="📈" onClick={() => onNavigate('kpi-input')} />
<HeroQuickLink label="Executive Reports" icon="📊" onClick={() => onNavigate('reports')} />
<HeroQuickLink label="Organizations Hierarchy" icon="🏢" onClick={() => onNavigate('organizations')} />
</>
) : (
<>
<HeroQuickLink label="Incidents" icon="⚠️" onClick={() => onNavigate('incidents')} />
<HeroQuickLink label="Ideas" icon="💡" onClick={() => onNavigate('ideas')} />
<HeroQuickLink label="Orders" icon="📦" onClick={() => onNavigate('orders')} />
<HeroQuickLink label="Processes" icon="⚙️" onClick={() => onNavigate('processes')} />
<HeroQuickLink label="Inbox" icon="📥" onClick={() => onNavigate('inbox')} />
<HeroQuickLink label="Reports" icon="📊" onClick={() => onNavigate('reports')} />
</>
)}
</div>
</div>
</div>
{/* Main Dashboard Views */}
{showLevel2 ? (
<Level2Dashboard
dashData={dashData}
selectedOrg={selectedOrg}
onNavigate={onNavigate}
/>
) : (
<Level1Dashboard
dashData={dashData}
fi={fi} fid={fid} fo={fo} fp={fp}
incidentStatuses={incidentStatuses}
ideaStatuses={ideaStatuses}
orderStatuses={orderStatuses}
processStatuses={processStatuses}
onNavigate={onNavigate}
onNavigateStatus={onNavigateStatus}
/>
)}
</div>
)
}

View File

@ -0,0 +1,230 @@
import { Dispatch, SetStateAction } from 'react'
import { useT } from '../i18n'
import { Delegation, Organization, User, Page, Incident, Idea } from '../types'
import { inp, lbl, chk, usersWithRole, isSoftDeleted, FormCard, DeletedBanner } from '../components/common'
import FormPageLayout from '../components/FormPageLayout'
import OrgSelect from '../components/OrgSelect'
import LinkSelect from '../components/LinkSelect'
import { colors } from '../constants'
interface DelegationFormPageProps {
form: Delegation
setForm: Dispatch<SetStateAction<Delegation>>
organizations: Organization[]
users: any[]
userRoleMap: Record<string, string[]>
incidents?: Incident[] // for link picker (pre-filtered to Initial Evaluation)
ideas?: Idea[] // for link picker (pre-filtered to Initial Evaluation)
formStatus: string
viewMode?: boolean
onEdit?: () => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
const STATUS_COLOR: Record<string, string> = {
Pending: colors.orange,
Accepted: colors.green,
Rejected: colors.red,
Revoked: colors.gray,
}
function fmtDateTime(d?: string) {
if (!d) return '—'
const dt = new Date(d)
if (isNaN(dt.getTime())) return '—'
return dt.toLocaleString()
}
export default function DelegationFormPage({
form, setForm, organizations, users, userRoleMap, incidents = [], ideas = [],
formStatus, viewMode, onEdit, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile,
onSubmit, onBack, onNavigate, onLogout, user
}: DelegationFormPageProps) {
const t = useT()
const set = (patch: Partial<Delegation>) => setForm(f => ({ ...f, ...patch }))
const di: React.CSSProperties = viewMode ? { background: '#f8fafc' } : {}
const managerItems = usersWithRole(users, userRoleMap, 'SB Manager', form.organization)
.filter(u => !isSoftDeleted(u) && u.name !== user.name)
.map(u => ({ ...u, name: u.name, label: u.full_name || u.name }))
const ieIncidents = (incidents || [])
.filter(i => !isSoftDeleted(i) && (!form.organization || i.organization === form.organization) && (i.workflow_state === 'Initial Evaluation' || i.status === 'Initial Evaluation'))
.map(i => ({ ...i, name: i.name, label: `${i.number || i.name} — ${i.title}` }))
const ieIdeas = (ideas || [])
.filter(i => !isSoftDeleted(i) && (!form.organization || i.organization === form.organization) && (i.workflow_state === 'Initial Evaluation' || i.status === 'Initial Evaluation'))
.map(i => ({ ...i, name: i.name, label: `${i.number || i.name} — ${i.title}` }))
const title = form.name ? `${t.formTitle.viewDelegation}${form.name}` : t.formTitle.newDelegation
return (
<FormPageLayout
title={title} activePage="delegations"
user={user} formStatus={formStatus}
statusBadge={form.status ? (
<span style={{
display: 'inline-block', padding: '3px 10px', borderRadius: 20, fontSize: 12, fontWeight: 700,
background: `${STATUS_COLOR[form.status] || colors.gray}18`,
color: STATUS_COLOR[form.status] || colors.gray,
border: `1px solid ${STATUS_COLOR[form.status] || colors.gray}40`,
}}>
{form.status}
</span>
) : undefined}
buttonText={t.saveDelegation} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{(form as any).custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={(form as any).custom_deleted_by}
deletedOn={(form as any).custom_deleted_on}
reason={(form as any).custom_deleted_reason}
onRestore={onRestore && form.name ? () => onRestore(form.name) : undefined}
onPermanentDelete={onPermanentDelete && form.name ? () => onPermanentDelete(form.name) : undefined}
/>
)}
{form.status === 'Revoked' && (
<div style={{ padding: '10px 14px', borderRadius: 8, background: '#f8fafc', border: '1px solid #cbd5e1', color: '#475569', fontSize: 13, fontWeight: 600, marginBottom: 14, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 16 }}>🚫</span> This delegation has been Revoked and is closed for further edits or re-delegation.
</div>
)}
<FormCard title="Delegation Information">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16, marginBottom: 14 }}>
<div>
<label style={lbl}>{t.col.auditor || 'Delegator'}</label>
<input style={{ ...inp, background: '#f8fafc', fontWeight: 600, color: '#334155' }} value={form.auditor || user.name} disabled />
</div>
<div>
<label style={lbl}>{t.col.manager} <span style={{ color: '#e74c3c' }}>*</span></label>
<LinkSelect
value={form.manager}
onChange={v => set({ manager: v })}
items={managerItems}
placeholder="-- Select Manager --"
disabled={viewMode}
/>
</div>
<div>
<label style={lbl}>{t.col.organization}</label>
<OrgSelect
value={form.organization}
onChange={v => set({ organization: v, incident_name: '', idea_name: '' })}
organizations={organizations}
placeholder="-- All Organizations --"
disabled={viewMode}
userRole={user.role}
/>
</div>
</div>
<div>
<label style={lbl}>{t.col.functions} <span style={{ color: '#e74c3c' }}>*</span></label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 6 }}>
{/* Incident function */}
<div className={`delegation-func-box ${form.incident_initial_evaluation ? 'checked' : ''}`}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: viewMode ? 'default' : 'pointer', fontSize: 13, fontWeight: 600 }}>
<input type="checkbox" style={chk} checked={!!form.incident_initial_evaluation} disabled={viewMode}
onChange={e => set({ incident_initial_evaluation: e.target.checked ? 1 : 0, incident_name: e.target.checked ? form.incident_name : '' })} />
<span className="func-title">Initial Evaluation (Incidents)</span>
</label>
{!!form.incident_initial_evaluation && (
<div style={{ marginTop: 10 }}>
<label style={{ ...lbl, fontSize: 11 }} className="func-sublabel">Specific Incident <span style={{ fontWeight: 400 }} className="func-hint">(optional — leave blank to delegate all)</span></label>
{viewMode ? (
<input style={{ ...inp, background: '#f8fafc' }} value={form.incident_name || '— All Incidents in Initial Evaluation'} readOnly disabled />
) : (
<LinkSelect
value={form.incident_name || ''}
onChange={v => set({ incident_name: v })}
items={ieIncidents}
placeholder={`-- Any Incident in Initial Evaluation (${ieIncidents.length} available) --`}
disabled={viewMode}
/>
)}
</div>
)}
</div>
{/* Idea function */}
<div className={`delegation-func-box ${form.idea_initial_evaluation ? 'checked' : ''}`}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: viewMode ? 'default' : 'pointer', fontSize: 13, fontWeight: 600 }}>
<input type="checkbox" style={chk} checked={!!form.idea_initial_evaluation} disabled={viewMode}
onChange={e => set({ idea_initial_evaluation: e.target.checked ? 1 : 0, idea_name: e.target.checked ? form.idea_name : '' })} />
<span className="func-title">Initial Evaluation (Ideas)</span>
</label>
{!!form.idea_initial_evaluation && (
<div style={{ marginTop: 10 }}>
<label style={{ ...lbl, fontSize: 11 }} className="func-sublabel">Specific Idea <span style={{ fontWeight: 400 }} className="func-hint">(optional — leave blank to delegate all)</span></label>
{viewMode ? (
<input style={{ ...inp, background: '#f8fafc' }} value={form.idea_name || '— All Ideas in Initial Evaluation'} readOnly disabled />
) : (
<LinkSelect
value={form.idea_name || ''}
onChange={v => set({ idea_name: v })}
items={ieIdeas}
placeholder={`-- Any Idea in Initial Evaluation (${ieIdeas.length} available) --`}
disabled={viewMode}
/>
)}
</div>
)}
</div>
</div>
</div>
{!viewMode && (
<div style={{ marginTop: 14 }}>
<label style={lbl}>Remarks</label>
<textarea
style={{ ...inp, ...di, minHeight: 70, resize: 'vertical' }}
value={form.remarks || ''}
onChange={e => set({ remarks: e.target.value })}
placeholder="Optional note for the Manager…"
/>
</div>
)}
</FormCard>
{viewMode && (form.delegation_log?.length ?? 0) > 0 && (
<FormCard title="Audit Trail">
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8fafc' }}>
<th style={{ padding: '8px 12px', textAlign: 'left', color: '#475569', fontSize: 11, fontWeight: 700, borderBottom: '1px solid #e2e8f0' }}>Action</th>
<th style={{ padding: '8px 12px', textAlign: 'left', color: '#475569', fontSize: 11, fontWeight: 700, borderBottom: '1px solid #e2e8f0' }}>Actor</th>
<th style={{ padding: '8px 12px', textAlign: 'left', color: '#475569', fontSize: 11, fontWeight: 700, borderBottom: '1px solid #e2e8f0' }}>Timestamp</th>
<th style={{ padding: '8px 12px', textAlign: 'left', color: '#475569', fontSize: 11, fontWeight: 700, borderBottom: '1px solid #e2e8f0' }}>Remarks</th>
</tr>
</thead>
<tbody>
{(form.delegation_log || []).map((row, i) => (
<tr key={row.name || i} style={{ borderBottom: '1px solid #f1f5f9' }}>
<td style={{ padding: '8px 12px', fontWeight: 600, color: '#334155' }}>{row.action}</td>
<td style={{ padding: '8px 12px', color: '#334155' }}>{row.actor}</td>
<td style={{ padding: '8px 12px', color: '#64748b' }}>{fmtDateTime(row.timestamp)}</td>
<td style={{ padding: '8px 12px', color: '#64748b' }}>{row.remarks || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</FormCard>
)}
</FormPageLayout>
)
}

View File

@ -0,0 +1,196 @@
import { useEffect, useMemo, useState } from 'react'
import { Delegation, Organization } from '../types'
import StatCard from '../components/StatCard'
import FilterBar from '../components/FilterBar'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { colors } from '../constants'
import { useT } from '../i18n'
interface DelegationsPageProps {
delegations: Delegation[]
organizations: Organization[]
userRole: string
currentUser: string
onView?: (d: Delegation) => void
onRevoke?: (name: string) => void
onSelectionChange?: (selected: Delegation[], page: Delegation[]) => void
}
const FUNCTION_LABELS: Record<string, string> = {
incident_initial_evaluation: 'Incident Initial Evaluation',
idea_initial_evaluation: 'Idea Initial Evaluation',
}
const STATUS_COLOR: Record<string, string> = {
Pending: colors.orange,
Accepted: colors.green,
Rejected: colors.red,
Revoked: colors.gray,
}
function fmtDate(d?: string) {
if (!d) return '—'
const dt = new Date(d)
if (isNaN(dt.getTime())) return '—'
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')}/${dt.getFullYear()}`
}
function functionsOf(d: Delegation): string[] {
const out: string[] = []
if (d.incident_initial_evaluation) out.push('incident_initial_evaluation')
if (d.idea_initial_evaluation) out.push('idea_initial_evaluation')
return out
}
export default function DelegationsPage({ delegations: allDelegations, organizations, userRole, currentUser, onView, onRevoke, onSelectionChange }: DelegationsPageProps) {
const t = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [orgFilter, setOrgFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }
const delegations = useMemo(() => allDelegations.filter(d => {
if ((d as any).custom_is_deleted === 1 || (d as any).custom_is_deleted === '1' || (d as any).custom_is_deleted === true || (d as any).is_deleted === 1 || (d as any).status === 'Deleted') return false
const label = `${d.auditor} ${d.manager}`.toLowerCase()
if (search && !label.includes(search.toLowerCase())) return false
if (orgFilter && d.organization !== orgFilter) return false
if (statusFilter !== 'all' && d.status !== statusFilter) return false
return true
}), [allDelegations, search, orgFilter, statusFilter])
const [page, setPage] = useState(1)
useEffect(() => { setPage(1) }, [search, orgFilter, statusFilter])
const pageRows = delegations.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = allDelegations.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows])
const pending = delegations.filter(d => d.status === 'Pending').length
const accepted = delegations.filter(d => d.status === 'Accepted').length
return (
<>
<div style={{ display: 'flex', gap: 16 }}>
<StatCard label="Total" val={delegations.length} color={colors.purple} />
<StatCard label="Pending" val={pending} color={colors.orange} />
<StatCard label="Accepted" val={accepted} color={colors.green} />
</div>
<FilterBar filters={[
{ type: 'search', label: 'Search', value: search, onChange: setSearch, placeholder: 'Auditor or Manager…' },
{ type: 'select', label: t.col.organization, value: orgFilter, onChange: setOrgFilter, options: [
{ value: '', label: 'All Organizations' }, ...organizations.map(o => ({ value: o.organization_name || o.name, label: o.organization_name || o.name })),
] },
{ type: 'select', label: 'Status', value: statusFilter, onChange: setStatusFilter, options: [
{ value: 'all', label: 'All' },
{ value: 'Pending', label: 'Pending' }, { value: 'Accepted', label: 'Accepted' },
{ value: 'Rejected', label: 'Rejected' }, { value: 'Revoked', label: 'Revoked' },
] },
]} />
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8f9fa', position: 'sticky', top: 0 }}>
<th style={{ width: 40, padding: '10px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={th}>{t.col.number}</th>
<th style={th}>{t.col.auditor}</th>
<th style={th}>{t.col.manager}</th>
<th style={th}>{t.col.organization}</th>
<th style={th}>{t.col.functions}</th>
<th style={th}>{t.col.status}</th>
<th style={th}>{t.col.requestedOn}</th>
<th style={th}>{t.col.actions}</th>
</tr>
</thead>
<tbody>
{delegations.length === 0
? <tr><td colSpan={9} style={{ padding: 40, textAlign: 'center', color: '#aaa' }}>{t.noData}</td></tr>
: pageRows.map((d, i) => {
const canRevoke = !!onRevoke && (d.status === 'Pending' || d.status === 'Accepted') &&
(userRole === 'Admin' || d.auditor === currentUser)
return (
<tr
key={d.name}
className="app-table-row"
onClick={() => onView?.(d)}
style={{
borderTop: '1px solid #f0f0f0',
background: selectedIds.has(d.name) ? '#eff6ff' : (i % 2 === 0 ? 'white' : '#fafafa'),
cursor: 'pointer'
}}
>
<td style={{ width: 40, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(d.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(d.name) ? next.delete(d.name) : next.add(d.name)
setSelectedIds(next)
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{d.number || d.name}</td>
<td style={{ padding: '10px 14px' }}>{d.auditor}</td>
<td style={{ padding: '10px 14px' }}>{d.manager}</td>
<td style={{ padding: '10px 14px' }}>{d.organization || 'All Organizations'}</td>
<td style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{functionsOf(d).map(f => (
<span key={f} style={{ background: '#ede9fe', color: '#6d28d9', padding: '2px 8px', borderRadius: 10, fontSize: 11, fontWeight: 600 }}>
{FUNCTION_LABELS[f] || f}
</span>
))}
</div>
</td>
<td style={{ padding: '10px 14px' }}>
<span style={{ background: (STATUS_COLOR[d.status] || colors.gray) + '22', color: STATUS_COLOR[d.status] || colors.gray, padding: '3px 10px', borderRadius: 12, fontSize: 12, fontWeight: 700 }}>
{d.status}
</span>
</td>
<td style={{ padding: '10px 14px', color: '#666' }}>{fmtDate(d.requested_on)}</td>
<td style={{ padding: '8px 14px' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={() => onView?.(d)} title="View"
style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', border: 'none', cursor: 'pointer', background: '#e0f7fa', color: '#0891b2' }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
{canRevoke && (
<button onClick={() => onRevoke?.(d.name)} title="Revoke"
style={{ padding: '0 12px', height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: 'none', cursor: 'pointer', background: '#fee2e2', color: '#ef4444', fontSize: 12, fontWeight: 700 }}>
Revoke
</button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<Pagination page={page} total={delegations.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,121 @@
import { useT } from '../i18n'
import { Dispatch, SetStateAction } from 'react'
import { Department, Management, Organization, User, Page, SBGroupCompany } from '../types'
import { inp, lbl, activeItems, FormCard, DeletedBanner } from '../components/common'
import FormPageLayout from '../components/FormPageLayout'
import OrgSelect from '../components/OrgSelect'
import LinkSelect from '../components/LinkSelect'
import DateInput from '../components/DateInput'
import { DOCTYPES } from '../constants'
interface DepartmentFormPageProps {
form: Department
setForm: Dispatch<SetStateAction<Department>>
organizations: Organization[]
groupCompanies?: SBGroupCompany[]
managements: Management[]
formStatus: string
viewMode?: boolean
onEdit?: () => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
export default function DepartmentFormPage({
form, setForm, organizations, groupCompanies: _gc = [], managements, formStatus, viewMode, onEdit, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile,
onSubmit, onBack, onNavigate, onLogout, user
}: DepartmentFormPageProps) {
const t = useT()
const set = (patch: Partial<Department>) => setForm(f => ({ ...f, ...patch }))
const di: React.CSSProperties = viewMode ? { background: '#f8fafc' } : {}
const title = form.name
? (viewMode ? `${t.formTitle.editDept}: ${form.department_name || form.name}` : t.formTitle.editDept)
: t.formTitle.newDept
const mgmtItems = activeItems(managements, form.custom_management).map(m => ({ name: m.name, label: m.name1 || m.name }))
return (
<FormPageLayout
title={title} activePage="departments"
user={user} formStatus={formStatus}
buttonText={t.saveDept} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{(form as any).custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={(form as any).custom_deleted_by}
deletedOn={(form as any).custom_deleted_on}
reason={(form as any).custom_deleted_reason}
onRestore={onRestore && form.name ? () => onRestore(form.name) : undefined}
onPermanentDelete={onPermanentDelete && form.name ? () => onPermanentDelete(form.name) : undefined}
/>
)}
<FormCard title="Department Details">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
<div>
<label style={lbl}>{t.field.organization} <span style={{ color: '#e74c3c' }}>*</span></label>
<OrgSelect
value={form.custom_organization}
onChange={v => {
const orgCo = organizations.find(o => o.name === v)?.group_company
set({ custom_organization: v, ...(user.custom_group_company ? {} : { group_company: orgCo || form.group_company }) })
}}
organizations={organizations}
disabled={viewMode || !!user.custom_organization}
userRole={user.role}
/>
</div>
<div>
<label style={lbl}>{t.field.department} <span style={{ color: '#e74c3c' }}>*</span></label>
<input
style={{ ...inp, ...di }}
value={form.department_name}
disabled={viewMode || !!form.name}
onChange={e => set({ department_name: e.target.value })}
placeholder="Department name"
required
/>
</div>
<div>
<label style={lbl}>{t.field.management}</label>
<LinkSelect
value={form.custom_management}
onChange={v => set({ custom_management: v })}
items={mgmtItems}
placeholder={t.field.select}
disabled={viewMode}
userRole={user.role}
doctypeName={DOCTYPES.managements}
extraFields={{ organization: form.custom_organization }}
/>
</div>
<div>
<label style={lbl}>{t.field.creationDate}</label>
<DateInput value={form.custom_creation_date || ''} onChange={v => set({ custom_creation_date: v })} disabled={viewMode} style={{ ...inp, ...di }} />
</div>
<div>
<label style={lbl}>{t.field.status}</label>
<select style={{ ...inp, ...di }} value={form.custom_status || 'Active'} disabled={viewMode}
onChange={e => set({ custom_status: e.target.value })}>
<option value="Active">{t.field.statusActive}</option>
<option value="Inactive">{t.field.statusInactive}</option>
</select>
</div>
</div>
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,148 @@
import { useEffect, useMemo, useState } from 'react'
import { Department, Management } from '../types'
import { RowActions } from '../components/common'
import StatCard from '../components/StatCard'
import FilterBar from '../components/FilterBar'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { colors } from '../constants'
import { useT } from '../i18n'
interface DepartmentPageProps {
departments: Department[]
managements: Management[]
canModify?: boolean
onView?: (d: Department) => void
onEdit?: (d: Department) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: Department[], page: Department[]) => void
}
function fmtDate(d?: string) {
if (!d) return '—'
const dt = new Date(d)
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')}/${dt.getFullYear()}`
}
export default function DepartmentPage({ departments: allDepartments, managements, canModify, onView, onEdit, onDelete, onSelectionChange }: DepartmentPageProps) {
const t = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [mgmtFilter, setMgmtFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const getMgmtLabel = (name: string) => managements.find(m => m.name === name)?.name1 || name || '—'
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }
const departments = useMemo(() => allDepartments.filter(d => {
const label = (d.department_name || d.name || '').toLowerCase()
if (search && !label.includes(search.toLowerCase())) return false
if (mgmtFilter && d.custom_management !== mgmtFilter) return false
if (statusFilter !== 'all' && (d.custom_status || 'Active') !== statusFilter) return false
return true
}), [allDepartments, search, mgmtFilter, statusFilter])
const [page, setPage] = useState(1)
useEffect(() => { setPage(1) }, [search, mgmtFilter, statusFilter])
const pageRows = departments.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = allDepartments.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows])
return (
<>
<div style={{ display: 'flex', gap: 16 }}>
<StatCard label="Total" val={departments.length} color={colors.teal} />
</div>
<FilterBar filters={[
{ type: 'search', label: 'Search', value: search, onChange: setSearch, placeholder: 'Search by name…' },
{ type: 'select', label: t.col.management, value: mgmtFilter, onChange: setMgmtFilter, options: [
{ value: '', label: 'All Managements' }, ...managements.map(m => ({ value: m.name, label: m.name1 || m.name })),
] },
{ type: 'select', label: 'Status', value: statusFilter, onChange: setStatusFilter, options: [
{ value: 'all', label: 'All' }, { value: 'Active', label: t.field.statusActive }, { value: 'Inactive', label: t.field.statusInactive },
] },
]} />
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8f9fa', position: 'sticky', top: 0 }}>
<th style={{ width: 40, padding: '10px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={th}>{t.col.name}</th>
<th style={th}>{t.col.management}</th>
<th style={th}>{t.field.creationDate}</th>
<th style={th}>{t.field.status}</th>
<th style={th}>{t.col.actions}</th>
</tr>
</thead>
<tbody>
{departments.length === 0
? <tr><td colSpan={6} style={{ padding: 40, textAlign: 'center', color: '#aaa' }}>{t.noData}</td></tr>
: pageRows.map((d, i) => (
<tr
key={d.name}
className="app-table-row"
onClick={() => onView?.(d)}
style={{
borderTop: '1px solid #f0f0f0',
background: selectedIds.has(d.name) ? '#eff6ff' : ((d as any).custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer'
}}
>
<td style={{ width: 40, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(d.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(d.name) ? next.delete(d.name) : next.add(d.name)
setSelectedIds(next)
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{d.department_name || d.name || '—'}</td>
<td style={{ padding: '10px 14px' }}>{getMgmtLabel(d.custom_management)}</td>
<td style={{ padding: '10px 14px', color: '#666' }}>{fmtDate(d.custom_creation_date)}</td>
<td style={{ padding: '10px 14px' }}>
{(d as any).custom_is_deleted ? (
<span style={{ background: '#fee2e2', color: '#991b1b', padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 700 }}>
{t.deletedBadge || 'DELETED'}
</span>
) : (
<span style={{ color: d.custom_status === 'Inactive' ? '#e74c3c' : '#16a34a', fontWeight: 600 }}>
{d.custom_status === 'Inactive' ? t.field.statusInactive : t.field.statusActive}
</span>
)}
</td>
<td style={{ padding: '8px 14px' }} onClick={e => e.stopPropagation()}>
<RowActions onView={() => onView?.(d)} onEdit={canModify && onEdit && !(d as any).custom_is_deleted ? () => onEdit(d) : undefined} onDelete={canModify && onDelete && !(d as any).custom_is_deleted ? () => onDelete(d.name) : undefined} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={departments.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,143 @@
import { useEffect, useMemo, useState } from 'react'
import { colors } from '../constants'
import { SBGroupCompany } from '../types'
import StatCard from '../components/StatCard'
import FilterBar from '../components/FilterBar'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { badge, RowActions } from '../components/common'
import { useT } from '../i18n'
interface GroupCompaniesPageProps {
groupCompanies: SBGroupCompany[]
canModify?: boolean
onView?: (item: SBGroupCompany) => void
onEdit?: (item: SBGroupCompany) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: SBGroupCompany[], page: SBGroupCompany[]) => void
}
export default function GroupCompaniesPage({ groupCompanies: allItems, canModify, onView, onEdit, onDelete, onSelectionChange }: GroupCompaniesPageProps) {
const t = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }
const groupCompanies = useMemo(() => allItems.filter(o => {
const label = [o.company_name, o.acronym, o.code, o.owner_user].filter(Boolean).join(' ').toLowerCase()
if (search && !label.includes(search.toLowerCase())) return false
if (statusFilter !== 'all' && (o.status || 'Active') !== statusFilter) return false
return true
}), [allItems, search, statusFilter])
const [page, setPage] = useState(1)
useEffect(() => { setPage(1) }, [search, statusFilter])
const pageRows = groupCompanies.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = allItems.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows])
return (
<>
<div style={{ display: 'flex', gap: 16 }}>
<StatCard label="Total Group Cos" val={groupCompanies.length} color="#1a2332" />
<StatCard label="Active" val={groupCompanies.filter(o => (o.status || 'Active') === 'Active').length} color={colors.green} />
<StatCard label="Inactive" val={groupCompanies.filter(o => o.status === 'Inactive').length} color={colors.orange} />
</div>
<FilterBar filters={[
{ type: 'search', label: 'Search', value: search, onChange: setSearch, placeholder: 'Company name, code, owner…' },
{ type: 'select', label: 'Status', value: statusFilter, onChange: setStatusFilter, options: [
{ value: 'all', label: 'All' }, { value: 'Active', label: 'Active' }, { value: 'Inactive', label: 'Inactive' },
] },
]} />
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid #f0f0f0', background: '#fafafa' }}>
<th style={{ width: 40, padding: '10px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={th}>Company Name</th>
<th style={th}>Code / Acronym</th>
<th style={th}>Group Co Owner</th>
<th style={th}>Status</th>
<th style={th}>{t.col.actions}</th>
</tr>
</thead>
<tbody>
{pageRows.map((gc, i) => {
const isDel = gc.custom_is_deleted === 1
return (
<tr
key={gc.name}
className="app-table-row"
onClick={() => onView?.(gc)}
style={{
borderBottom: '1px solid #f0f0f0',
background: selectedIds.has(gc.name) ? '#eff6ff' : (isDel ? '#fef2f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer',
transition: 'background 0.15s ease'
}}
>
<td style={{ width: 40, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(gc.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(gc.name) ? next.delete(gc.name) : next.add(gc.name)
setSelectedIds(next)
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '12px 14px', fontWeight: 600 }}>
{gc.company_name}
{isDel && <span style={{ marginLeft: 8 }}>{badge(t.deletedBadge || 'DELETED', '#ef4444')}</span>}
</td>
<td style={{ padding: '12px 14px', color: '#555' }}>
{[gc.code, gc.acronym].filter(Boolean).join(' / ') || '—'}
</td>
<td style={{ padding: '12px 14px', color: '#555' }}>{gc.owner_user || '—'}</td>
<td style={{ padding: '12px 14px' }}>
{badge(gc.status || 'Active', (gc.status || 'Active') === 'Active' ? colors.green : colors.orange)}
</td>
<td style={{ padding: '12px 14px' }} onClick={e => e.stopPropagation()}>
<RowActions
onView={onView ? () => onView(gc) : undefined}
onEdit={canModify && onEdit ? () => onEdit(gc) : undefined}
onDelete={canModify && onDelete ? () => onDelete(gc.name) : undefined}
/>
</td>
</tr>
)
})}
{groupCompanies.length === 0 && (
<tr><td colSpan={6} style={{ padding: 24, textAlign: 'center', color: '#aaa' }}>No Group Companies found</td></tr>
)}
</tbody>
</table>
</div>
<Pagination page={page} total={groupCompanies.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,154 @@
import { useEffect } from 'react'
import { SBGroupCompany, User, Page } from '../types'
import { inp, lbl, isSoftDeleted, DeletedBanner, FormCard } from '../components/common'
import FormPageLayout from '../components/FormPageLayout'
import LinkSelect from '../components/LinkSelect'
interface GroupCompanyFormPageProps {
form: SBGroupCompany
setForm: (f: SBGroupCompany) => void
users?: User[]
formStatus: string
viewMode?: boolean
onEdit?: () => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
export default function GroupCompanyFormPage({
form, setForm, users = [], formStatus, viewMode, onEdit, onRestore, onPermanentDelete, darkMode,
notifCount, onToggleDark, onCountChange, onViewProfile, onSubmit, onBack, onNavigate, onLogout, user
}: GroupCompanyFormPageProps) {
const set = (patch: Partial<SBGroupCompany>) => setForm({ ...form, ...patch })
const di: React.CSSProperties = viewMode ? { background: '#f8fafc' } : {}
useEffect(() => {
if (!form.owner_user && !viewMode) {
const matchingAdmin = users.find(u =>
u.custom_group_company === form.name &&
(u.role === 'Admin' || (u as any).custom_category === 'Admin')
)
if (matchingAdmin) {
set({ owner_user: matchingAdmin.name })
} else if (!form.name && (user.role === 'Admin' || user.role === 'CEO')) {
set({ owner_user: user.name })
}
}
}, [form.name, form.owner_user, viewMode, users, user.name, user.role])
const title = form.name
? (viewMode ? `View Group Company: ${form.company_name}` : 'Edit Group Company')
: 'New Group Company'
const userItems = (users || []).filter(u => !isSoftDeleted(u)).map(u => ({ ...u, name: u.name, label: u.full_name ? `${u.full_name} (${u.name})` : u.name }))
return (
<FormPageLayout
title={title}
activePage="group-companies"
user={user}
formStatus={formStatus}
buttonText="Save Group Company"
buttonColor="#16a34a"
viewMode={viewMode}
onEdit={onEdit}
darkMode={darkMode}
notifCount={notifCount}
onToggleDark={onToggleDark}
onCountChange={onCountChange}
onViewProfile={onViewProfile}
onSubmit={onSubmit}
onBack={onBack}
onNavigate={onNavigate}
onLogout={onLogout}
>
{form.custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={form.custom_deleted_by}
deletedOn={form.custom_deleted_on}
reason={form.custom_deleted_reason}
onRestore={onRestore ? () => onRestore(form.name) : undefined}
onPermanentDelete={onPermanentDelete ? () => onPermanentDelete(form.name) : undefined}
/>
)}
<FormCard title="Group Company Details">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16, marginBottom: 14 }}>
<div>
<label style={lbl}>Company Name <span style={{ color: '#e74c3c' }}>*</span></label>
<input
style={{ ...inp, ...di }}
value={form.company_name || ''}
disabled={viewMode || !!form.name}
onChange={e => set({ company_name: e.target.value })}
placeholder="e.g. Company A Group"
/>
</div>
<div>
<label style={lbl}>Company Code</label>
<input
style={{ ...inp, ...di }}
value={form.code || ''}
disabled={viewMode}
onChange={e => set({ code: e.target.value })}
placeholder="e.g. CO-A"
/>
</div>
<div>
<label style={lbl}>Acronym</label>
<input
style={{ ...inp, ...di }}
value={form.acronym || ''}
disabled={viewMode}
onChange={e => set({ acronym: e.target.value })}
placeholder="e.g. CAG"
/>
</div>
<div>
<label style={lbl}>Status</label>
<select
style={{ ...inp, ...di }}
value={form.status || 'Active'}
disabled={viewMode}
onChange={e => set({ status: e.target.value })}
>
<option value="Active">Active</option>
<option value="Inactive">Inactive</option>
</select>
</div>
<div>
<label style={lbl}>Group Co Owner / Admin User</label>
<LinkSelect
value={form.owner_user || ''}
onChange={v => set({ owner_user: v })}
items={userItems}
placeholder="Select owner user"
disabled={viewMode}
/>
</div>
</div>
<div>
<label style={lbl}>Notes</label>
<textarea
style={{ ...inp, ...di, height: 75, resize: 'vertical' }}
value={form.notes || ''}
disabled={viewMode}
onChange={e => set({ notes: e.target.value })}
placeholder="Additional details regarding this tenant company…"
/>
</div>
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,590 @@
import { useState } from 'react'
import { colors, DOCTYPES } from '../constants'
import { Organization, Management, Department, Branch, IdeaType, IdeaStatus, User, Page, SBGroupCompany } from '../types'
import { inp, lbl, usersWithRole, activeItems, filterOrgItems, DeletedBanner, DeleteRecordCard, FormCard, StatusSummaryCard } from '../components/common'
import DateInput from '../components/DateInput'
import FormPageLayout from '../components/FormPageLayout'
import OrgSelect from '../components/OrgSelect'
import LinkSelect from '../components/LinkSelect'
import CommentSection from '../components/CommentSection'
import ActivityLog from '../components/ActivityLog'
import { applyIdeaWorkflow } from '../api'
import { useT } from '../i18n'
interface IdeaForm {
name: string
number: string; title: string; organization: string; group_company?: string; type: string
management: string; department: string; section_field: string; branch: string
current_condition: string; proposed_condition: string; status: string
assigned_to: string; workflow_state: string
// Classification
idea_class: string; hses_field: string; date: string
// Auditor review
priority: string; economy_class: string
product_relation: number; repeated: number; entity_scope: string
// Manager solution
solution: string; cost: number
// Comment
auditor_comment: string
sb_order: string
custom_is_deleted?: number
custom_deleted_by?: string
custom_deleted_on?: string
custom_deleted_reason?: string
}
interface IdeaFormPageProps {
ideaForm: IdeaForm
setIdeaForm: React.Dispatch<React.SetStateAction<any>> | ((f: IdeaForm) => void)
organizations: Organization[]
groupCompanies?: SBGroupCompany[]
managements: Management[]
departments: Department[]
branches: Branch[]
ideaTypes: IdeaType[]
ideaStatuses: IdeaStatus[]
users?: any[]
userRoleMap?: Record<string, string[]>
formStatus: string
viewMode?: boolean
canModify?: boolean
onEdit?: () => void
onDelete?: (name: string) => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
onNavigateToOrder?: (orderName: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
userRole?: string | null
delegatedFunctions?: string[]
delegatedOrgs?: Record<string, string[] | null>
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
onRefresh?: () => void
}
const IDEA_STATE_BADGE: Record<string, React.CSSProperties> = {
New: { background: '#eff6ff', color: '#1e40af', border: '1px solid #bfdbfe' },
'Initial Evaluation': { background: '#fff7ed', color: '#c2410c', border: '1px solid #fed7aa' },
'Accept & Under Progress': { background: '#fefce8', color: '#92400e', border: '1px solid #fde68a' },
'working on proposal': { background: '#f5f3ff', color: '#6d28d9', border: '1px solid #ddd6fe' },
Rejected: { background: '#fef2f2', color: '#dc2626', border: '1px solid #fecaca' },
'Executed & Closed': { background: '#f0fdf4', color: '#166534', border: '1px solid #bbf7d0' },
}
function ideaStateBadge(state: string): React.CSSProperties {
return {
display: 'inline-block', padding: '4px 12px', borderRadius: 20,
fontSize: 13, fontWeight: 700,
...(IDEA_STATE_BADGE[state] ?? { background: '#f3f4f6', color: '#374151', border: '1px solid #e5e7eb' }),
}
}
export default function IdeaFormPage({
ideaForm, setIdeaForm, organizations, groupCompanies: _gc = [], managements, departments, branches,
ideaTypes, ideaStatuses: _ideaStatuses, users = [], userRoleMap = {}, formStatus, viewMode, canModify, onEdit, onDelete, onRestore, onPermanentDelete, onNavigateToOrder, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile, userRole,
delegatedFunctions = [], delegatedOrgs = {}, onSubmit, onBack, onNavigate, onLogout, user, onRefresh
}: IdeaFormPageProps) {
const tl = useT()
const set = (patch: Partial<IdeaForm>) => setIdeaForm({ ...ideaForm, ...patch })
const di: React.CSSProperties = viewMode ? { background: '#f9f9f9' } : {}
const managerUsers = usersWithRole(users, userRoleMap, 'SB Manager', ideaForm.organization)
const [wfStatus, setWfStatus] = useState('')
const [wfLoading, setWfLoading] = useState(false)
const title = ideaForm.name
? (viewMode ? `${tl.formTitle.viewIdea}${ideaForm.number || ideaForm.title || ideaForm.name}` : tl.formTitle.editIdea)
: tl.formTitle.newIdea
const filteredMgmts = filterOrgItems(managements, { organization: ideaForm.organization })
const filteredDepts = filterOrgItems(departments, { organization: ideaForm.organization, management: ideaForm.management })
const filteredBranches = filterOrgItems(branches, { organization: ideaForm.organization, management: ideaForm.management, department: ideaForm.department })
const mgmtItems = activeItems(filteredMgmts, ideaForm.management).map(m => ({ name: m.name, label: m.name1 || m.name }))
const deptItems = activeItems(filteredDepts, ideaForm.department).map(d => ({ name: d.name, label: d.department_name || d.name }))
const branchItems = activeItems(filteredBranches, ideaForm.branch).map(b => ({ name: b.name, label: b.branch || b.name }))
const typeItems = activeItems(ideaTypes, ideaForm.type).map(t => ({ name: t.name, label: t.name1 || t.name }))
const wfState = ideaForm.workflow_state || 'New'
const isEmployee = userRole === 'Employee'
const isAuditorAdmin = userRole === 'Auditor' || userRole === 'Admin'
const isManager = userRole === 'Manager'
const hasIdeaDelegation = isManager && delegatedFunctions.includes('idea_initial_evaluation') &&
(!delegatedOrgs['idea_initial_evaluation'] || (delegatedOrgs['idea_initial_evaluation'] || []).includes(ideaForm.organization))
const canEvaluateIdea = isAuditorAdmin || hasIdeaDelegation
type IdeaAction = { label: string; action: string; color: string }
const availableActions: IdeaAction[] = (() => {
if (!ideaForm.name) return []
if (wfState === 'New')
return [{ label: tl.wf.submit, action: 'Submit', color: '#2563eb' }]
if (wfState === 'Initial Evaluation' && canEvaluateIdea)
return [
{ label: tl.wf.assignToManager, action: 'Assign to Manager', color: '#d97706' },
{ label: tl.wf.reject, action: 'Reject', color: '#dc2626' },
]
if (wfState === 'Accept & Under Progress' && (isManager || isAuditorAdmin))
return [{ label: tl.wf.submitSolution, action: 'Submit Proposal', color: '#059669' }]
if (wfState === 'working on proposal' && isAuditorAdmin)
return [
{ label: tl.wf.moveToOutsourcing, action: 'Move to Outsourcing', color: '#0369a1' },
{ label: tl.wf.reject, action: 'Reject', color: '#dc2626' },
]
return []
})()
async function doAction(action: string) {
if (!ideaForm.name) return
setWfLoading(true); setWfStatus('')
try {
const extra: Record<string, string> = {}
if (action === 'Assign to Manager') {
if (ideaForm.assigned_to) extra.assigned_to = ideaForm.assigned_to
// save all evaluation fields the Auditor filled in
if (ideaForm.priority) extra.priority = ideaForm.priority
if (ideaForm.economy_class) extra.economy_class = ideaForm.economy_class
if (ideaForm.entity_scope) extra.entity_scope = ideaForm.entity_scope
if (ideaForm.hses_field) extra.hses_field = ideaForm.hses_field
extra.product_relation = String(ideaForm.product_relation ?? 0)
extra.repeated = String(ideaForm.repeated ?? 0)
}
if (action === 'Submit Proposal') {
if (ideaForm.solution) extra.solution = ideaForm.solution
if (ideaForm.proposed_condition) extra.proposed_condition = ideaForm.proposed_condition
if (ideaForm.cost) extra.cost = String(ideaForm.cost)
}
if (ideaForm.auditor_comment && (action === 'Move to Outsourcing' || action === 'Reject')) {
extra.auditor_comment = ideaForm.auditor_comment
}
const res = await applyIdeaWorkflow(ideaForm.name, action, JSON.stringify(extra))
if (res?.created_order) {
set({ sb_order: res.created_order, workflow_state: 'Executed & Closed', status: 'Executed & Closed' })
}
setWfStatus('Done')
onRefresh?.()
} catch (e: any) {
setWfStatus(e.message || 'Error')
} finally {
setWfLoading(false)
}
}
const assignedManagerName = managerUsers.find((u: any) => u.name === ideaForm.assigned_to)?.full_name || ideaForm.assigned_to
const rightPanel = ideaForm.name ? (
<div style={{ width: 240, display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Workflow Actions Card */}
<div style={{ background: 'white', borderRadius: 10, padding: 14, border: '1px solid #e2e8f0', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#1e293b', marginBottom: 10 }}>
{tl.wf.workflow}
</div>
{/* Current State Subbox */}
<div style={{
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 8,
padding: '10px 12px',
marginBottom: 12
}}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.4px', marginBottom: 4 }}>
Current State
</div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#1e293b' }}>
{wfState}
</div>
</div>
{wfState === 'Initial Evaluation' && canEvaluateIdea && (
<div style={{ marginBottom: 10 }}>
<label style={lbl}>{tl.wf.assignToManager}</label>
<select style={inp} value={ideaForm.assigned_to || ''} onChange={e => set({ assigned_to: e.target.value })}>
<option value="">{tl.field.selectMgr}</option>
{managerUsers.map((u: any) => <option key={u.name} value={u.name}>{u.full_name || u.name}</option>)}
</select>
</div>
)}
{wfState === 'Accept & Under Progress' && (
<div style={{ marginBottom: 10 }}>
<label style={lbl}>{tl.field.solution}</label>
<textarea style={{ ...inp, height: 75, resize: 'vertical' }}
value={ideaForm.solution || ''}
onChange={e => set({ solution: e.target.value })}
placeholder={tl.field.solutionPlaceholder} />
<label style={{ ...lbl, marginTop: 6 }}>{tl.field.actualCost}</label>
<input type="number" style={inp} value={ideaForm.cost || ''} onChange={e => set({ cost: parseFloat(e.target.value) || 0 })} placeholder="0.00" />
</div>
)}
{wfState === 'working on proposal' && isAuditorAdmin && (
<div style={{ marginBottom: 10 }}>
<label style={lbl}>{tl.field.auditorComment}</label>
<textarea style={{ ...inp, height: 60, resize: 'vertical' }}
value={ideaForm.auditor_comment || ''}
onChange={e => set({ auditor_comment: e.target.value })}
placeholder={tl.field.commentPlaceholder} />
</div>
)}
{availableActions.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{availableActions.map(t => (
<button key={t.action} onClick={() => doAction(t.action)} disabled={wfLoading}
style={{
width: '100%',
padding: '8px 12px',
borderRadius: 6,
border: 'none',
background: t.color,
color: 'white',
fontWeight: 600,
fontSize: 12,
cursor: wfLoading ? 'not-allowed' : 'pointer',
opacity: wfLoading ? 0.7 : 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6
}}>
<span>{wfLoading ? tl.wf.applying : t.label}</span>
</button>
))}
</div>
) : (
<div style={{ fontSize: 11, color: '#94a3b8', fontStyle: 'italic' }}>{tl.wf.noActions}</div>
)}
{wfStatus && <p style={{ marginTop: 8, fontSize: 12, color: wfStatus === 'Done' ? colors.green : colors.red }}>{wfStatus}</p>}
</div>
{/* Status Summary Card */}
<StatusSummaryCard
priority={ideaForm.priority}
status={wfState}
assignedTo={assignedManagerName || (ideaForm.assigned_to ? ideaForm.assigned_to : undefined)}
createdDate={ideaForm.date}
modifiedDate={(ideaForm as any).modified}
/>
<CommentSection doctype={DOCTYPES.ideas} docname={ideaForm.name} currentUserName={user.name} />
<ActivityLog doctype={DOCTYPES.ideas} docname={ideaForm.name} docCreation={(ideaForm as any).creation} docOwner={(ideaForm as any).owner} />
{canModify && onDelete && !ideaForm.custom_is_deleted && (
<DeleteRecordCard
title="Delete Request"
btnText={tl.delete || 'Delete'}
onDelete={() => onDelete(ideaForm.name)}
/>
)}
</div>
) : null
return (
<FormPageLayout
title={title}
activePage="ideas"
user={user} formStatus={formStatus}
statusBadge={<span style={ideaStateBadge(wfState)}>{wfState}</span>}
buttonText={tl.saveIdea} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
rightPanel={rightPanel}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{ideaForm.custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={ideaForm.custom_deleted_by}
deletedOn={ideaForm.custom_deleted_on}
reason={ideaForm.custom_deleted_reason}
onRestore={onRestore ? () => onRestore(ideaForm.name) : undefined}
onPermanentDelete={onPermanentDelete ? () => onPermanentDelete(ideaForm.name) : undefined}
/>
)}
{ideaForm.sb_order && (
<div style={{
background: '#f0fdf4',
border: '1px solid #bbf7d0',
borderRadius: 8,
padding: '8px 14px',
marginBottom: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
fontSize: 12,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, overflow: 'hidden' }}>
<span style={{ fontSize: 14, flexShrink: 0 }}>📦</span>
<span style={{ fontSize: 11, fontWeight: 700, color: '#15803d', textTransform: 'uppercase', letterSpacing: '0.4px', whiteSpace: 'nowrap' }}>
{tl.field.convertedToOrder}:
</span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#14532d', whiteSpace: 'nowrap' }}>
{ideaForm.sb_order}
</span>
</div>
{onNavigateToOrder && (
<button
type="button"
onClick={() => onNavigateToOrder(ideaForm.sb_order!)}
style={{
background: '#16a34a',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '4px 10px',
fontWeight: 600,
fontSize: 11,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
flexShrink: 0,
transition: 'background 0.15s ease',
}}
onMouseEnter={e => (e.currentTarget.style.background = '#15803d')}
onMouseLeave={e => (e.currentTarget.style.background = '#16a34a')}
>
{tl.field.viewOrder} →
</button>
)}
</div>
)}
{/* Section 1: Idea Information */}
<FormCard title="Idea Information">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
{ideaForm.name && (
<div>
<label style={lbl}>{tl.field.number}</label>
<input style={{ ...inp, background: '#f8fafc', color: '#475569', fontWeight: 600 }} value={ideaForm.number} readOnly />
</div>
)}
<div>
<label style={lbl}>{tl.field.organization} <span style={{ color: '#e74c3c' }}>*</span></label>
<OrgSelect
value={ideaForm.organization}
onChange={v => {
const orgCo = organizations.find(o => o.name === v)?.group_company
set({ organization: v, ...(user.custom_group_company ? {} : { group_company: orgCo || ideaForm.group_company }) })
}}
organizations={organizations}
disabled={viewMode || !!user.custom_organization}
userRole={userRole}
onQuickCreateSuccess={onRefresh}
/>
</div>
<div>
<label style={lbl}>{tl.field.title}</label>
<input style={{ ...inp, ...di }} value={ideaForm.title} disabled={viewMode} onChange={e => set({ title: e.target.value })} />
</div>
<div>
<label style={lbl}>{tl.field.management}</label>
<LinkSelect
value={ideaForm.management}
onChange={v => set({ management: v })}
items={mgmtItems}
placeholder={tl.field.select}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.managements}
extraFields={{ organization: ideaForm.organization }}
onQuickCreateSuccess={onRefresh}
/>
</div>
<div>
<label style={lbl}>{tl.field.department}</label>
<LinkSelect
value={ideaForm.department}
onChange={v => set({ department: v })}
items={deptItems}
placeholder={tl.field.select}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.departments}
extraFields={{ organization: ideaForm.organization, management: ideaForm.management }}
onQuickCreateSuccess={onRefresh}
/>
</div>
<div>
<label style={lbl}>{tl.field.section}</label>
<input style={{ ...inp, ...di }} value={ideaForm.section_field || ''} disabled={viewMode} onChange={e => set({ section_field: e.target.value })} />
</div>
<div>
<label style={lbl}>{tl.field.branch}</label>
<LinkSelect
value={ideaForm.branch}
onChange={v => set({ branch: v })}
items={branchItems}
placeholder={tl.field.select}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.branches}
extraFields={{ organization: ideaForm.organization, management: ideaForm.management, department: ideaForm.department }}
onQuickCreateSuccess={onRefresh}
/>
</div>
<div>
<label style={lbl}>{tl.field.type}</label>
<LinkSelect
value={ideaForm.type}
onChange={v => set({ type: v })}
items={typeItems}
placeholder={tl.field.selectType}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.ideaTypes}
onQuickCreateSuccess={onRefresh}
/>
</div>
<div>
<label style={lbl}>{tl.field.ideaClass}</label>
<select style={{ ...inp, ...di }} value={ideaForm.idea_class || ''} disabled={viewMode} onChange={e => set({ idea_class: e.target.value })}>
<option value="">{tl.field.select}</option>
<option value="Safety">Safety</option>
<option value="Non-safety">Non-safety</option>
</select>
</div>
<div>
<label style={lbl}>{tl.field.hsesField}</label>
<input style={{ ...inp, ...di }} value={ideaForm.hses_field || ''} disabled={viewMode} onChange={e => set({ hses_field: e.target.value })} placeholder="Safety, Health, Environment…" />
</div>
<div>
<label style={lbl}>{tl.field.date}</label>
<DateInput value={ideaForm.date || ''} onChange={v => set({ date: v })} disabled style={{ ...inp, background: '#f8fafc' }} />
</div>
<div>
<label style={lbl}>{tl.field.assignedTo}</label>
{viewMode || userRole === 'Employee' ? (
<input
style={{ ...inp, background: '#f8fafc', fontWeight: 600, color: '#334155' }}
value={assignedManagerName || ideaForm.assigned_to || '—'}
readOnly
disabled
/>
) : (
<LinkSelect
value={ideaForm.assigned_to || ''}
onChange={v => set({ assigned_to: v })}
items={managerUsers.map(u => ({ name: u.name, label: u.full_name || u.name }))}
placeholder={tl.field.select}
/>
)}
</div>
</div>
</FormCard>
{/* Section 2: Current & Proposed Conditions */}
<FormCard title="Condition Details & Description">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
<div>
<label style={lbl}>{tl.field.currentCondition}</label>
<textarea
style={{ ...inp, ...di, height: 100, resize: 'vertical' }}
value={ideaForm.current_condition}
disabled={viewMode}
onChange={e => set({ current_condition: e.target.value })}
placeholder="Describe the current condition or problem..."
/>
</div>
<div>
<label style={lbl}>{tl.field.proposedCondition}</label>
<textarea
style={{ ...inp, ...di, height: 100, resize: 'vertical' }}
value={ideaForm.proposed_condition}
disabled={viewMode}
onChange={e => set({ proposed_condition: e.target.value })}
placeholder="Describe the proposed solution or improvement..."
/>
</div>
</div>
</FormCard>
{/* Section 3: Auditor Evaluation (Hidden from Employee) */}
{!isEmployee && (
<FormCard title={tl.steps.auditorEval}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 12 }}>
<div>
<label style={lbl}>{tl.field.priority}</label>
<select style={{ ...inp, ...(viewMode || !canEvaluateIdea ? { background: '#f8fafc' } : {}) }}
value={ideaForm.priority || ''} disabled={viewMode || !canEvaluateIdea}
onChange={e => set({ priority: e.target.value })}>
<option value="">{tl.field.select}</option>
<option value="High">{tl.field.high}</option>
<option value="Normal">{tl.field.normal}</option>
</select>
</div>
<div>
<label style={lbl}>{tl.field.economyClass}</label>
<select style={{ ...inp, ...(viewMode || !canEvaluateIdea ? { background: '#f8fafc' } : {}) }}
value={ideaForm.economy_class || ''} disabled={viewMode || !canEvaluateIdea}
onChange={e => set({ economy_class: e.target.value })}>
<option value="">{tl.field.select}</option>
<option value="Expenses">{tl.field.expenses}</option>
<option value="Capital">{tl.field.capital}</option>
<option value="Earn">{tl.field.earn}</option>
<option value="Pay/Loss">{tl.field.payLoss}</option>
</select>
</div>
<div>
<label style={lbl}>{tl.field.entityScope}</label>
<select style={{ ...inp, ...(viewMode || !canEvaluateIdea ? { background: '#f8fafc' } : {}) }}
value={ideaForm.entity_scope || ''} disabled={viewMode || !canEvaluateIdea}
onChange={e => set({ entity_scope: e.target.value })}>
<option value="">{tl.field.select}</option>
<option value="Upstream">{tl.field.upstream}</option>
<option value="On-stream">{tl.field.onstream}</option>
<option value="Downstream">{tl.field.downstream}</option>
</select>
</div>
</div>
<div style={{ display: 'flex', gap: 20, marginTop: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
<input type="checkbox" checked={!!ideaForm.product_relation} disabled={viewMode || !canEvaluateIdea}
onChange={e => set({ product_relation: e.target.checked ? 1 : 0 })} /> {tl.field.productRelation}
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
<input type="checkbox" checked={!!ideaForm.repeated} disabled={viewMode || !canEvaluateIdea}
onChange={e => set({ repeated: e.target.checked ? 1 : 0 })} /> {tl.field.repeatedEvent}
</label>
</div>
</FormCard>
)}
{/* Section 4: Manager Solution (Hidden from Employee) */}
{!isEmployee && (
<FormCard title={tl.steps.managerSolution}>
<div style={{ marginBottom: 12 }}>
<label style={lbl}>{tl.field.solution}</label>
<textarea style={{ ...inp, height: 90, resize: 'vertical',
...(viewMode && !isManager && !isAuditorAdmin ? { background: '#f8fafc' } : {}) }}
value={ideaForm.solution || ''} disabled={viewMode && !isManager && !isAuditorAdmin}
onChange={e => set({ solution: e.target.value })} placeholder={tl.field.solutionPlaceholder} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
<div>
<label style={lbl}>{tl.field.actualCost}</label>
<input type="number" style={{ ...inp,
...(viewMode && !isManager && !isAuditorAdmin ? { background: '#f8fafc' } : {}) }}
value={ideaForm.cost || ''} disabled={viewMode && !isManager && !isAuditorAdmin}
onChange={e => set({ cost: parseFloat(e.target.value) || 0 })} placeholder="0.00" />
</div>
<div>
<label style={lbl}>{tl.field.auditorComment}</label>
<textarea style={{ ...inp, height: 60, resize: 'vertical',
...(viewMode && !isAuditorAdmin ? { background: '#f8fafc' } : {}) }}
value={ideaForm.auditor_comment || ''} disabled={viewMode && !isAuditorAdmin}
onChange={e => set({ auditor_comment: e.target.value })} placeholder={tl.field.commentPlaceholder} />
</div>
</div>
</FormCard>
)}
</FormPageLayout>
)
}

View File

@ -0,0 +1,108 @@
import { useT } from '../i18n'
import { Dispatch, SetStateAction } from 'react'
import { IdeaStatus, User, Page } from '../types'
import { inp, lbl, chk, FormCard, DeletedBanner } from '../components/common'
import DateInput from '../components/DateInput'
import FormPageLayout from '../components/FormPageLayout'
interface Props {
form: IdeaStatus
setForm: Dispatch<SetStateAction<IdeaStatus>>
formStatus: string
viewMode?: boolean
onEdit?: () => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
export default function IdeaStatusFormPage({ form, setForm, formStatus, viewMode, onEdit, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile, onSubmit, onBack, onNavigate, onLogout, user }: Props) {
const t = useT()
const set = (patch: Partial<IdeaStatus>) => setForm(f => ({ ...f, ...patch }))
const di: React.CSSProperties = viewMode ? { background: '#f8fafc' } : {}
const title = form.name
? (viewMode ? `Idea Status: ${form.name1 || form.name}` : 'Edit Idea Status')
: 'New Idea Status'
return (
<FormPageLayout
title={title} activePage="idea-statuses"
user={user} formStatus={formStatus}
buttonText={t.saveName} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{(form as any).custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={(form as any).custom_deleted_by}
deletedOn={(form as any).custom_deleted_on}
reason={(form as any).custom_deleted_reason}
onRestore={onRestore && form.name ? () => onRestore(form.name) : undefined}
onPermanentDelete={onPermanentDelete && form.name ? () => onPermanentDelete(form.name) : undefined}
/>
)}
<FormCard title="Idea Status Details">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16, marginBottom: 14 }}>
<div>
<label style={lbl}>{t.field.name1} <span style={{ color: '#e74c3c' }}>*</span></label>
<input
style={{ ...inp, ...di }}
value={form.name1}
disabled={viewMode || !!form.name}
onChange={e => set({ name1: e.target.value })}
placeholder="Idea status name"
required
/>
</div>
<div>
<label style={lbl}>{t.field.creationDate}</label>
<DateInput
value={form.creation_date || ''}
onChange={v => set({ creation_date: v })}
disabled={viewMode}
style={{ ...inp, ...di }}
/>
</div>
<div>
<label style={lbl}>{t.field.status}</label>
<select
style={{ ...inp, ...di }}
value={form.status || 'Active'}
disabled={viewMode}
onChange={e => set({ status: e.target.value })}
>
<option value="Active">{t.field.statusActive}</option>
<option value="Inactive">{t.field.statusInactive}</option>
</select>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
id="notify_manager"
checked={!!form.notify_manager}
disabled={viewMode}
onChange={e => set({ notify_manager: e.target.checked ? 1 : 0 })}
style={chk}
/>
<label htmlFor="notify_manager" style={{ fontSize: 13, cursor: viewMode ? 'default' : 'pointer' }}>
{t.field.notifyManager}
</label>
</div>
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,141 @@
import { useEffect, useMemo, useState } from 'react'
import { IdeaStatus } from '../types'
import { RowActions } from '../components/common'
import StatCard from '../components/StatCard'
import FilterBar from '../components/FilterBar'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { colors } from '../constants'
import { useT } from '../i18n'
interface Props {
ideaStatuses: IdeaStatus[]
canModify?: boolean
onView?: (t: IdeaStatus) => void
onEdit?: (t: IdeaStatus) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: IdeaStatus[], page: IdeaStatus[]) => void
}
function fmtDate(d?: string) {
if (!d) return '—'
const dt = new Date(d)
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')}/${dt.getFullYear()}`
}
export default function IdeaStatusPage({ ideaStatuses: allIdeaStatuses, canModify, onView, onEdit, onDelete, onSelectionChange }: Props) {
const tl = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }
const ideaStatuses = useMemo(() => allIdeaStatuses.filter(ia => {
const label = (ia.name1 || ia.name || '').toLowerCase()
if (search && !label.includes(search.toLowerCase())) return false
if (statusFilter !== 'all' && (ia.status || 'Active') !== statusFilter) return false
return true
}), [allIdeaStatuses, search, statusFilter])
const [page, setPage] = useState(1)
useEffect(() => { setPage(1) }, [search, statusFilter])
const pageRows = ideaStatuses.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = allIdeaStatuses.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows])
return (
<>
<div style={{ display: 'flex', gap: 16 }}>
<StatCard label="Total" val={ideaStatuses.length} color={colors.teal} />
</div>
<FilterBar filters={[
{ type: 'search', label: 'Search', value: search, onChange: setSearch, placeholder: 'Search by name…' },
{ type: 'select', label: 'Status', value: statusFilter, onChange: setStatusFilter, options: [
{ value: 'all', label: 'All' }, { value: 'Active', label: tl.field.statusActive }, { value: 'Inactive', label: tl.field.statusInactive },
] },
]} />
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8f9fa', position: 'sticky', top: 0 }}>
<th style={{ width: 40, padding: '10px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={th}>{tl.col.name}</th>
<th style={th}>{tl.field.notifyManager}</th>
<th style={th}>{tl.field.creationDate}</th>
<th style={th}>{tl.field.status}</th>
<th style={th}>{tl.col.actions}</th>
</tr>
</thead>
<tbody>
{ideaStatuses.length === 0
? <tr><td colSpan={6} style={{ padding: 40, textAlign: 'center', color: '#aaa' }}>{tl.noData}</td></tr>
: pageRows.map((ia, i) => (
<tr
key={ia.name}
className="app-table-row"
onClick={() => onView?.(ia)}
style={{
borderTop: '1px solid #f0f0f0',
background: selectedIds.has(ia.name) ? '#eff6ff' : ((ia as any).custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer'
}}
>
<td style={{ width: 40, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(ia.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(ia.name) ? next.delete(ia.name) : next.add(ia.name)
setSelectedIds(next)
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{ ia.name1 || ia.name || '—'}</td>
<td style={{ padding: '10px 14px' }}>{ ia.notify_manager ? tl.field.statusActive : '—'}</td>
<td style={{ padding: '10px 14px', color: '#666' }}>{fmtDate(ia.creation_date)}</td>
<td style={{ padding: '10px 14px' }}>
{(ia as any).custom_is_deleted ? (
<span style={{ background: '#fee2e2', color: '#991b1b', padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 700 }}>
{tl.deletedBadge || 'DELETED'}
</span>
) : (
<span style={{ color: ia.status === 'Inactive' ? '#e74c3c' : '#16a34a', fontWeight: 600 }}>
{ ia.status === 'Inactive' ? tl.field.statusInactive : tl.field.statusActive}
</span>
)}
</td>
<td style={{ padding: '8px 14px' }} onClick={e => e.stopPropagation()}>
<RowActions onView={() => onView?.(ia)} onEdit={canModify && onEdit && !(ia as any).custom_is_deleted ? () => onEdit(ia) : undefined} onDelete={canModify && onDelete && !(ia as any).custom_is_deleted ? () => onDelete(ia.name) : undefined} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={ideaStatuses.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,94 @@
import { useT } from '../i18n'
import { Dispatch, SetStateAction } from 'react'
import { IdeaType, User, Page } from '../types'
import { inp, lbl, FormCard, DeletedBanner } from '../components/common'
import DateInput from '../components/DateInput'
import FormPageLayout from '../components/FormPageLayout'
interface Props {
form: IdeaType
setForm: Dispatch<SetStateAction<IdeaType>>
formStatus: string
viewMode?: boolean
onEdit?: () => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
export default function IdeaTypeFormPage({ form, setForm, formStatus, viewMode, onEdit, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile, onSubmit, onBack, onNavigate, onLogout, user }: Props) {
const t = useT()
const set = (patch: Partial<IdeaType>) => setForm(f => ({ ...f, ...patch }))
const di: React.CSSProperties = viewMode ? { background: '#f8fafc' } : {}
const title = form.name
? (viewMode ? `Idea Type: ${form.name1 || form.name}` : 'Edit Idea Type')
: 'New Idea Type'
return (
<FormPageLayout
title={title} activePage="idea-types"
user={user} formStatus={formStatus}
buttonText={t.saveName} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{(form as any).custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={(form as any).custom_deleted_by}
deletedOn={(form as any).custom_deleted_on}
reason={(form as any).custom_deleted_reason}
onRestore={onRestore && form.name ? () => onRestore(form.name) : undefined}
onPermanentDelete={onPermanentDelete && form.name ? () => onPermanentDelete(form.name) : undefined}
/>
)}
<FormCard title="Idea Type Details">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
<div>
<label style={lbl}>{t.field.name1} <span style={{ color: '#e74c3c' }}>*</span></label>
<input
style={{ ...inp, ...di }}
value={form.name1}
disabled={viewMode || !!form.name}
onChange={e => set({ name1: e.target.value })}
placeholder="Idea type name"
required
/>
</div>
<div>
<label style={lbl}>{t.field.creationDate}</label>
<DateInput
value={form.creation_date || ''}
onChange={v => set({ creation_date: v })}
disabled={viewMode}
style={{ ...inp, ...di }}
/>
</div>
<div>
<label style={lbl}>{t.field.status}</label>
<select
style={{ ...inp, ...di }}
value={form.status || 'Active'}
disabled={viewMode}
onChange={e => set({ status: e.target.value })}
>
<option value="Active">{t.field.statusActive}</option>
<option value="Inactive">{t.field.statusInactive}</option>
</select>
</div>
</div>
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from 'react'
import { IdeaType } from '../types'
import { RowActions } from '../components/common'
import StatCard from '../components/StatCard'
import FilterBar from '../components/FilterBar'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { colors } from '../constants'
import { useT } from '../i18n'
interface Props {
ideaTypes: IdeaType[]
canModify?: boolean
onView?: (t: IdeaType) => void
onEdit?: (t: IdeaType) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: IdeaType[], page: IdeaType[]) => void
}
function fmtDate(d?: string) {
if (!d) return '—'
const dt = new Date(d)
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')}/${dt.getFullYear()}`
}
export default function IdeaTypePage({ ideaTypes: allIdeaTypes, canModify, onView, onEdit, onDelete, onSelectionChange }: Props) {
const t = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }
const ideaTypes = useMemo(() => allIdeaTypes.filter(ia => {
const label = (ia.name1 || ia.name || '').toLowerCase()
if (search && !label.includes(search.toLowerCase())) return false
if (statusFilter !== 'all' && (ia.status || 'Active') !== statusFilter) return false
return true
}), [allIdeaTypes, search, statusFilter])
const [page, setPage] = useState(1)
useEffect(() => { setPage(1) }, [search, statusFilter])
const pageRows = ideaTypes.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = allIdeaTypes.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows])
return (
<>
<div style={{ display: 'flex', gap: 16 }}>
<StatCard label="Total" val={ideaTypes.length} color={colors.teal} />
</div>
<FilterBar filters={[
{ type: 'search', label: 'Search', value: search, onChange: setSearch, placeholder: 'Search by name…' },
{ type: 'select', label: 'Status', value: statusFilter, onChange: setStatusFilter, options: [
{ value: 'all', label: 'All' }, { value: 'Active', label: t.field.statusActive }, { value: 'Inactive', label: t.field.statusInactive },
] },
]} />
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8f9fa', position: 'sticky', top: 0 }}>
<th style={{ width: 40, padding: '10px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={th}>{t.col.name}</th>
<th style={th}>{t.field.creationDate}</th>
<th style={th}>{t.field.status}</th>
<th style={th}>{t.col.actions}</th>
</tr>
</thead>
<tbody>
{ideaTypes.length === 0
? <tr><td colSpan={5} style={{ padding: 40, textAlign: 'center', color: '#aaa' }}>{t.noData}</td></tr>
: pageRows.map((ia, i) => (
<tr
key={ia.name}
className="app-table-row"
onClick={() => onView?.(ia)}
style={{
borderTop: '1px solid #f0f0f0',
background: selectedIds.has(ia.name) ? '#eff6ff' : ((ia as any).custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer'
}}
>
<td style={{ width: 40, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(ia.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(ia.name) ? next.delete(ia.name) : next.add(ia.name)
setSelectedIds(next)
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{ ia.name1 || ia.name || '—'}</td>
<td style={{ padding: '10px 14px', color: '#666' }}>{fmtDate(ia.creation_date)}</td>
<td style={{ padding: '10px 14px' }}>
{(ia as any).custom_is_deleted ? (
<span style={{ background: '#fee2e2', color: '#991b1b', padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 700 }}>
{t.deletedBadge || 'DELETED'}
</span>
) : (
<span style={{ color: ia.status === 'Inactive' ? '#e74c3c' : '#16a34a', fontWeight: 600 }}>
{ ia.status === 'Inactive' ? t.field.statusInactive : t.field.statusActive}
</span>
)}
</td>
<td style={{ padding: '8px 14px' }} onClick={e => e.stopPropagation()}>
<RowActions onView={() => onView?.(ia)} onEdit={canModify && onEdit && !(ia as any).custom_is_deleted ? () => onEdit(ia) : undefined} onDelete={canModify && onDelete && !(ia as any).custom_is_deleted ? () => onDelete(ia.name) : undefined} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={ideaTypes.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,196 @@
import { useEffect, useState } from 'react'
import { Idea, Organization, Department, Branch, IdeaStatus } from '../types'
import { statusBadge, RowActions, isWorkflowClosed } from '../components/common'
import FilterSection, { fLbl, fInp } from '../components/FilterSection'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { useT } from '../i18n'
interface Props {
ideas: Idea[]
organizations: Organization[]
departments: Department[]
branches: Branch[]
ideaStatuses: IdeaStatus[]
initialOrg?: string
initialStatus?: string
canModify?: boolean
userRole?: string
onView?: (i: Idea) => void
onEdit?: (i: Idea) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: Idea[], page: Idea[]) => void
}
export default function IdeasPage({
ideas,
organizations,
departments,
branches,
ideaStatuses,
initialOrg,
initialStatus,
canModify,
userRole,
onView,
onEdit,
onDelete,
onSelectionChange,
}: Props) {
const t = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [f, setF] = useState({
search: '',
organization: initialOrg || '',
department: '',
branch: '',
status: initialStatus || '',
})
const set = (k: string, v: string) => setF(p => ({ ...p, [k]: v }))
const [page, setPage] = useState(1)
const rows = ideas.filter(idea => {
if (f.search && !`${idea.number} ${idea.title}`.toLowerCase().includes(f.search.toLowerCase())) return false
if (f.organization && idea.organization !== f.organization) return false
if (f.department && idea.department !== f.department) return false
if (f.branch && idea.branch !== f.branch) return false
if (f.status && idea.status !== f.status) return false
return true
})
useEffect(() => { setPage(1) }, [f])
const pageRows = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = ideas.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows, ideas])
return (
<>
<FilterSection>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '10px 14px' }}>
<div>
<label style={fLbl}>{t.filter.numberTitle}</label>
<input style={fInp} value={f.search} onChange={e => set('search', e.target.value)} placeholder={t.filter.searchPlaceholder} />
</div>
<div>
<label style={fLbl}>{t.filter.organization}</label>
<select style={fInp} value={f.organization} onChange={e => set('organization', e.target.value)}>
<option value="">{t.filter.allOrgs}</option>
{organizations.map(o => <option key={o.name} value={o.name}>{o.organization_name || o.name}</option>)}
</select>
</div>
<div>
<label style={fLbl}>{t.filter.department}</label>
<select style={fInp} value={f.department} onChange={e => set('department', e.target.value)}>
<option value="">{t.filter.allDepts}</option>
{departments.map(d => <option key={d.name} value={d.name}>{d.department_name || d.name}</option>)}
</select>
</div>
<div>
<label style={fLbl}>{t.filter.branch}</label>
<select style={fInp} value={f.branch} onChange={e => set('branch', e.target.value)}>
<option value="">{t.filter.allBranches}</option>
{branches.map(b => <option key={b.name} value={b.name}>{b.branch || b.name}</option>)}
</select>
</div>
<div>
<label style={fLbl}>{t.filter.status}</label>
<select style={fInp} value={f.status} onChange={e => set('status', e.target.value)}>
<option value="">{t.filter.allStatuses}</option>
{ideaStatuses.map(s => <option key={s.name} value={s.name}>{s.name1 || s.name}</option>)}
</select>
</div>
</div>
</FilterSection>
<div style={{ background: 'white', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.06)', overflow: 'hidden', border: '1px solid #f1f5f9' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f8fafc', color: '#64748b', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>
<th style={{ width: 44, padding: '12px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.number}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.title}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.organization}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.status}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.actions}</th>
</tr>
</thead>
<tbody>
{rows.length === 0
? <tr><td colSpan={6} style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>{t.noIdeas}</td></tr>
: pageRows.map((idea, i) => (
<tr
key={idea.name}
className="app-table-row"
onClick={() => onView?.(idea)}
style={{
borderTop: '1px solid #f1f5f9',
background: selectedIds.has(idea.name) ? '#eff6ff' : (idea.custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
>
<td style={{ width: 44, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(idea.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(idea.name) ? next.delete(idea.name) : next.add(idea.name)
setSelectedIds(next)
}}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '12px 16px', color: '#64748b', fontSize: 12, fontWeight: 500 }}>{idea.number || '—'}</td>
<td style={{ padding: '12px 16px', fontWeight: 600, color: '#1e293b' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span>{idea.title || '—'}</span>
{idea.sb_order && (
<span style={{ fontSize: 11, background: '#f0fdf4', color: '#15803d', border: '1px solid #bbf7d0', borderRadius: 9999, padding: '2px 8px', fontWeight: 600 }}>
📦 {idea.sb_order}
</span>
)}
</div>
</td>
<td style={{ padding: '12px 16px', color: '#475569' }}>{idea.organization || '—'}</td>
<td style={{ padding: '12px 16px' }}>{idea.custom_is_deleted ? statusBadge(t.deletedBadge || 'DELETED') : (idea.status ? statusBadge(idea.status) : '—')}</td>
<td style={{ padding: '10px 16px' }} onClick={e => e.stopPropagation()}>
<RowActions
onView={() => onView?.(idea)}
onEdit={
canModify && onEdit && !idea.custom_is_deleted && !isWorkflowClosed('idea', idea.workflow_state) && !(userRole === 'Employee' && idea.workflow_state && idea.workflow_state !== 'New')
? () => onEdit(idea)
: undefined
}
onDelete={canModify && onDelete && !idea.custom_is_deleted ? () => onDelete(idea.name) : undefined}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={rows.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,595 @@
import { useEffect, useState, useMemo } from 'react'
import { InboxItem, Page } from '../types'
import { getInboxItems, respondToDelegation } from '../api'
import { colors } from '../constants'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
interface InboxPageProps {
onNavigate: (page: Page, name?: string) => void
onRefresh?: () => void
}
const typeToPage: Record<string, Page> = {
process: 'processes-form',
order: 'orders-form',
incident: 'incidents-form',
idea: 'ideas-form',
}
const typeColor: Record<string, string> = {
process: '#3b82f6',
order: '#8b5cf6',
incident: '#ef4444',
idea: '#10b981',
delegation: '#f59e0b',
}
const typeGradient: Record<string, string> = {
process: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
order: 'linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%)',
incident: 'linear-gradient(135deg, #ef4444 0%, #b91c1c 100%)',
idea: 'linear-gradient(135deg, #10b981 0%, #047857 100%)',
delegation: 'linear-gradient(135deg, #f59e0b 0%, #b45309 100%)',
}
const typeIcon: Record<string, string> = {
process: '⚙️',
order: '📦',
incident: '🚨',
idea: '💡',
delegation: '🤝',
}
export default function InboxPage({ onNavigate, onRefresh }: InboxPageProps) {
const [items, setItems] = useState<InboxItem[]>([])
const [loading, setLoading] = useState(true)
const [busyName, setBusyName] = useState('')
const [errorName, setErrorName] = useState('')
const [search, setSearch] = useState('')
const [filterType, setFilterType] = useState<string>('all')
const [page, setPage] = useState(1)
const load = () => {
setLoading(true)
getInboxItems().then(data => {
setItems(data || [])
setLoading(false)
}).catch(() => setLoading(false))
}
useEffect(() => { load() }, [])
const counts = useMemo(() => {
const res: Record<string, number> = { all: items.length, action: 0, info: 0 }
for (const item of items) {
if (item.requires_action) res.action = (res.action || 0) + 1
else res.info = (res.info || 0) + 1
res[item.type] = (res[item.type] || 0) + 1
}
return res
}, [items])
const filteredItems = useMemo(() => {
return items.filter(item => {
if (filterType === 'action' && !item.requires_action) return false
if (filterType === 'info' && item.requires_action) return false
if (filterType !== 'all' && filterType !== 'action' && filterType !== 'info' && item.type !== filterType) return false
if (search.trim()) {
const q = search.toLowerCase()
const match = [item.number, item.title, item.action, item.organization, item.type].filter(Boolean).join(' ').toLowerCase()
if (!match.includes(q)) return false
}
return true
})
}, [items, filterType, search])
useEffect(() => { setPage(1) }, [filterType, search])
const pageItems = filteredItems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
const respond = async (name: string, response: 'Accepted' | 'Rejected') => {
setBusyName(name)
setErrorName('')
try {
await respondToDelegation(name, response)
await load()
onRefresh?.()
} catch {
setErrorName(name)
} finally {
setBusyName('')
}
}
return (
<div style={{ maxWidth: 1100, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Top Banner & Stats */}
<div style={{
background: 'linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #1e293b 100%)',
borderRadius: 16,
padding: '24px 28px',
color: '#ffffff',
boxShadow: '0 10px 25px -5px rgba(30, 27, 75, 0.25), 0 8px 10px -6px rgba(30, 27, 75, 0.2)',
display: 'flex',
flexDirection: 'column',
gap: 18,
position: 'relative',
overflow: 'hidden'
}}>
{/* Glow decoration */}
<div style={{
position: 'absolute',
top: -40,
right: -40,
width: 180,
height: 180,
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(99, 102, 241, 0.35) 0%, transparent 70%)',
pointerEvents: 'none'
}} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 14 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 24 }}>📬</span>
<h2 style={{ fontSize: 20, fontWeight: 800, color: '#ffffff', margin: 0, letterSpacing: '-0.3px' }}>
Unified Activity Inbox
</h2>
</div>
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#c7d2fe', opacity: 0.9 }}>
Real-time tasks, approvals, delegations, and workflow actions requiring your attention.
</p>
</div>
<button
type="button"
onClick={load}
disabled={loading}
style={{
background: 'rgba(255, 255, 255, 0.15)',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255, 255, 255, 0.25)',
color: 'white',
borderRadius: 10,
padding: '8px 16px',
fontSize: 13,
fontWeight: 600,
cursor: loading ? 'wait' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: 8,
transition: 'all 0.15s ease'
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = 'rgba(255, 255, 255, 0.25)' }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = 'rgba(255, 255, 255, 0.15)' }}
>
<span style={{ transform: loading ? 'rotate(180deg)' : 'none', transition: 'transform 0.5s', display: 'inline-block' }}>🔄</span>
{loading ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{/* Quick stat counters */}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div style={{
background: 'rgba(255, 255, 255, 0.1)',
backdropFilter: 'blur(6px)',
borderRadius: 10,
padding: '8px 14px',
display: 'flex',
alignItems: 'center',
gap: 10,
border: '1px solid rgba(255, 255, 255, 0.15)'
}}>
<span style={{ fontSize: 12, color: '#e0e7ff', fontWeight: 500 }}>Total Items</span>
<span style={{ background: 'white', color: '#1e1b4b', fontWeight: 800, fontSize: 12, padding: '2px 8px', borderRadius: 999 }}>
{counts.all || 0}
</span>
</div>
<div style={{
background: 'rgba(239, 68, 68, 0.2)',
backdropFilter: 'blur(6px)',
borderRadius: 10,
padding: '8px 14px',
display: 'flex',
alignItems: 'center',
gap: 10,
border: '1px solid rgba(239, 68, 68, 0.35)'
}}>
<span style={{ fontSize: 12, color: '#fecaca', fontWeight: 600 }}>Action Required</span>
<span style={{ background: '#ef4444', color: 'white', fontWeight: 800, fontSize: 12, padding: '2px 8px', borderRadius: 999 }}>
{counts.action || 0}
</span>
</div>
<div style={{
background: 'rgba(59, 130, 246, 0.2)',
backdropFilter: 'blur(6px)',
borderRadius: 10,
padding: '8px 14px',
display: 'flex',
alignItems: 'center',
gap: 10,
border: '1px solid rgba(59, 130, 246, 0.35)'
}}>
<span style={{ fontSize: 12, color: '#bfdbfe', fontWeight: 500 }}>Informational</span>
<span style={{ background: '#3b82f6', color: 'white', fontWeight: 800, fontSize: 12, padding: '2px 8px', borderRadius: 999 }}>
{counts.info || 0}
</span>
</div>
</div>
</div>
{/* Filter Tabs and Search Bar */}
<div style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: 14,
background: 'white',
padding: '12px 16px',
borderRadius: 12,
border: '1px solid #e2e8f0',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)'
}} className="form-card">
{/* Pills */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
{[
{ id: 'all', label: 'All', icon: '📋', count: counts.all },
{ id: 'action', label: 'Needs Action', icon: '🚨', count: counts.action, badgeColor: '#ef4444' },
{ id: 'info', label: 'Info', icon: 'ℹ️', count: counts.info },
{ id: 'incident', label: 'Incidents', icon: '⚠️', count: counts.incident },
{ id: 'idea', label: 'Ideas', icon: '💡', count: counts.idea },
{ id: 'order', label: 'Orders', icon: '📦', count: counts.order },
{ id: 'process', label: 'Processes', icon: '⚙️', count: counts.process },
{ id: 'delegation', label: 'Delegations', icon: '🤝', count: counts.delegation },
].map(tab => {
const active = filterType === tab.id
if (tab.id !== 'all' && tab.id !== 'action' && tab.id !== 'info' && (!tab.count || tab.count === 0)) return null
return (
<button
key={tab.id}
type="button"
onClick={() => setFilterType(tab.id)}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 12px',
borderRadius: 999,
border: active ? '1px solid #4f46e5' : '1px solid #e2e8f0',
background: active ? '#4f46e5' : '#f8fafc',
color: active ? '#ffffff' : '#475569',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
>
<span>{tab.icon}</span>
<span>{tab.label}</span>
{tab.count != null && tab.count > 0 && (
<span style={{
fontSize: 10,
fontWeight: 700,
padding: '1px 6px',
borderRadius: 999,
background: active ? 'rgba(255,255,255,0.25)' : (tab.badgeColor || '#e2e8f0'),
color: active ? '#ffffff' : (tab.badgeColor ? 'white' : '#475569')
}}>
{tab.count}
</span>
)}
</button>
)
})}
</div>
{/* Search */}
<div style={{ minWidth: 200, flex: '1 1 200px', maxWidth: 300 }}>
<input
type="text"
placeholder="Search items, titles, IDs…"
value={search}
onChange={e => setSearch(e.target.value)}
style={{
width: '100%',
padding: '7px 12px',
borderRadius: 8,
border: '1px solid #cbd5e1',
fontSize: 12,
boxSizing: 'border-box',
background: '#f8fafc',
color: '#334155'
}}
/>
</div>
</div>
{/* Cards List */}
{loading ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: '#94a3b8', fontSize: 14 }}>
<div style={{ fontSize: 28, marginBottom: 8, animation: 'spin 1s linear infinite' }}>⏳</div>
Loading inbox updates…
</div>
) : filteredItems.length === 0 ? (
<div style={{
background: 'white',
borderRadius: 16,
padding: '60px 24px',
textAlign: 'center',
border: '1px solid #e2e8f0',
boxShadow: '0 1px 3px rgba(0,0,0,0.03)'
}} className="form-card">
<div style={{ fontSize: 44, marginBottom: 12 }}>✨</div>
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#1e293b', margin: 0 }}>
{search || filterType !== 'all' ? 'No matching inbox items' : 'You are all caught up!'}
</h3>
<p style={{ fontSize: 13, color: '#64748b', marginTop: 6, maxWidth: 420, margin: '6px auto 0' }}>
{search || filterType !== 'all'
? 'Try changing your filter pills or searching for a different keyword.'
: 'There are no active pending approvals or tasks requiring your action right now.'}
</p>
{(search || filterType !== 'all') && (
<button
type="button"
onClick={() => { setSearch(''); setFilterType('all') }}
style={{
marginTop: 16,
padding: '7px 16px',
borderRadius: 8,
background: '#4f46e5',
color: 'white',
border: 'none',
fontWeight: 600,
fontSize: 12,
cursor: 'pointer'
}}
>
Reset Filters
</button>
)}
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))', gap: 14 }}>
{pageItems.map((item, idx) => {
const tint = typeColor[item.type] || colors.gray
const gradient = typeGradient[item.type] || 'linear-gradient(135deg, #64748b 0%, #475569 100%)'
const clickable = item.type !== 'delegation'
return (
<div
key={item.name || idx}
className="inbox-card form-card"
onClick={clickable ? () => onNavigate(typeToPage[item.type] as Page, item.name) : undefined}
style={{
background: 'white',
borderRadius: 14,
padding: '16px 18px',
border: '1px solid #e2e8f0',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
gap: 14,
cursor: clickable ? 'pointer' : 'default',
position: 'relative',
overflow: 'hidden',
transition: 'transform 0.15s ease, box-shadow 0.15s ease'
}}
onMouseEnter={e => {
if (clickable) {
(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
;(e.currentTarget as HTMLElement).style.boxShadow = '0 10px 20px -5px rgba(0,0,0,0.08)'
}
}}
onMouseLeave={e => {
if (clickable) {
(e.currentTarget as HTMLElement).style.transform = 'none'
;(e.currentTarget as HTMLElement).style.boxShadow = '0 1px 3px rgba(0,0,0,0.04)'
}
}}
>
{/* Accent line */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 4, background: gradient }} />
<div>
{/* Card Header row */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 32,
height: 32,
borderRadius: 8,
background: gradient,
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 15,
flexShrink: 0,
boxShadow: `0 3px 8px -1px ${tint}66`
}}>
{typeIcon[item.type] || '📌'}
</div>
<span style={{ fontSize: 12, fontWeight: 700, color: tint, letterSpacing: '0.3px' }}>
{item.number || item.name}
</span>
</div>
{item.requires_action ? (
<span style={{
fontSize: 10,
fontWeight: 800,
color: '#ef4444',
background: '#fef2f2',
border: '1px solid #fecaca',
padding: '2px 8px',
borderRadius: 999,
textTransform: 'uppercase',
letterSpacing: '0.4px',
display: 'flex',
alignItems: 'center',
gap: 4
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#ef4444' }} />
Action
</span>
) : (
<span style={{
fontSize: 10,
fontWeight: 700,
color: '#64748b',
background: '#f1f5f9',
padding: '2px 8px',
borderRadius: 999,
textTransform: 'uppercase',
letterSpacing: '0.3px'
}}>
{item.type}
</span>
)}
</div>
{/* Card Title */}
<div className="inbox-card-title" style={{
fontSize: 14,
fontWeight: 700,
color: '#1e293b',
lineHeight: 1.35,
marginBottom: 6,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical'
}}>
{item.title || item.action || 'Untitled Item'}
</div>
{/* Action description / remarks */}
<div style={{ fontSize: 12, color: '#475569', lineHeight: 1.4, marginBottom: 8 }}>
{item.action}
</div>
{/* Meta tag chips */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
{item.organization && (
<span style={{
fontSize: 11,
color: '#64748b',
background: '#f8fafc',
border: '1px solid #e2e8f0',
padding: '2px 7px',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
gap: 4
}}>
🏢 {item.organization}
</span>
)}
{item.date && (
<span style={{
fontSize: 11,
color: '#64748b',
background: '#f8fafc',
border: '1px solid #e2e8f0',
padding: '2px 7px',
borderRadius: 6
}}>
📅 {item.date}
</span>
)}
</div>
{errorName === item.name && (
<div style={{ marginTop: 8, fontSize: 11, color: '#ef4444', fontWeight: 600 }}>
⚠️ Failed to process response. Please try again.
</div>
)}
</div>
{/* Footer Action */}
<div>
{item.type === 'delegation' ? (
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button
type="button"
onClick={() => respond(item.name, 'Accepted')}
disabled={busyName === item.name}
style={{
flex: 1,
padding: '7px 0',
borderRadius: 8,
border: 'none',
background: '#16a34a',
color: 'white',
fontWeight: 700,
fontSize: 12,
cursor: busyName === item.name ? 'wait' : 'pointer',
opacity: busyName === item.name ? 0.6 : 1,
transition: 'background 0.15s ease'
}}
>
✓ Accept
</button>
<button
type="button"
onClick={() => respond(item.name, 'Rejected')}
disabled={busyName === item.name}
style={{
flex: 1,
padding: '7px 0',
borderRadius: 8,
border: 'none',
background: '#ef4444',
color: 'white',
fontWeight: 700,
fontSize: 12,
cursor: busyName === item.name ? 'wait' : 'pointer',
opacity: busyName === item.name ? 0.6 : 1,
transition: 'background 0.15s ease'
}}
>
✕ Reject
</button>
</div>
) : (
<button
type="button"
onClick={e => {
e.stopPropagation()
onNavigate(typeToPage[item.type] as Page, item.name)
}}
style={{
width: '100%',
padding: '7px 12px',
borderRadius: 8,
border: item.requires_action ? 'none' : '1px solid #cbd5e1',
background: item.requires_action ? gradient : '#f8fafc',
color: item.requires_action ? '#ffffff' : '#334155',
fontWeight: 700,
fontSize: 12,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
transition: 'all 0.15s ease'
}}
>
<span>{item.requires_action ? 'Review & Act Now' : 'View Details'}</span>
<span style={{ fontSize: 13 }}>→</span>
</button>
)}
</div>
</div>
)
})}
</div>
)}
<Pagination page={page} total={filteredItems.length} onPageChange={setPage} />
</div>
)
}

View File

@ -0,0 +1,574 @@
import { useState } from 'react'
import { colors, DOCTYPES } from '../constants'
import { Organization, Management, Department, Branch, IncidentType, IncidentClass, IncidentField, IncidentStatus, User, Page, SBGroupCompany } from '../types'
import { inp, lbl, usersWithRole, activeItems, filterOrgItems, DeletedBanner, DeleteRecordCard, FormCard, StatusSummaryCard } from '../components/common'
import DateInput from '../components/DateInput'
import FormPageLayout from '../components/FormPageLayout'
import OrgSelect from '../components/OrgSelect'
import LinkSelect from '../components/LinkSelect'
import CommentSection from '../components/CommentSection'
import ActivityLog from '../components/ActivityLog'
import { applyIncidentWorkflow } from '../api'
import { useT } from '../i18n'
interface IncidentForm {
name: string
number: string; title: string; organization: string; group_company?: string; event_category: string; type: string
incident_class: string; incident_field: string
date: string; time: string; management: string; department: string
section_field: string; branch: string; assigned_to: string; status: string
description: string; summary: string; workflow_state: string
// Creator data (auto-filled)
creator_name?: string; creator_id?: string
creator_division?: string; creator_department?: string
creator_section?: string; creator_branch?: string
// Auditor triage (Step 2)
hses_field: string; priority: string; economy_class: string
product_relation: number; repeated: number; entity_scope: string
// Manager solution (Step 3)
solution: string; cost: number
// Comment (Step 4)
auditor_comment: string
deadline_date?: string
custom_is_deleted?: number
custom_deleted_by?: string
custom_deleted_on?: string
custom_deleted_reason?: string
}
interface IncidentFormPageProps {
incForm: IncidentForm
setIncForm: any
organizations: Organization[]
groupCompanies?: SBGroupCompany[]
managements: Management[]
departments: Department[]
branches: Branch[]
incidentTypes: IncidentType[]
incidentClasses: IncidentClass[]
incidentFields: IncidentField[]
incidentStatuses: IncidentStatus[]
users: any[]
userRoleMap?: Record<string, string[]>
formStatus: string
viewMode?: boolean
canModify?: boolean
onEdit?: () => void
onDelete?: (name: string) => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
userRole?: string | null
delegatedFunctions?: string[]
delegatedOrgs?: Record<string, string[] | null>
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
onRefresh?: () => void
}
const INC_STATE_BADGE: Record<string, React.CSSProperties> = {
New: { background: '#eff6ff', color: '#1e40af', border: '1px solid #bfdbfe' },
'Initial Evaluation': { background: '#fff7ed', color: '#c2410c', border: '1px solid #fed7aa' },
'Accept & Under Progress': { background: '#fefce8', color: '#92400e', border: '1px solid #fde68a' },
'working on resolution': { background: '#f5f3ff', color: '#6d28d9', border: '1px solid #ddd6fe' },
Rejected: { background: '#fef2f2', color: '#dc2626', border: '1px solid #fecaca' },
'Executed & Closed': { background: '#f0fdf4', color: '#166534', border: '1px solid #bbf7d0' },
}
function incStateBadge(state: string): React.CSSProperties {
return {
display: 'inline-block', padding: '4px 12px', borderRadius: 20,
fontSize: 13, fontWeight: 700,
...(INC_STATE_BADGE[state] ?? { background: '#f3f4f6', color: '#374151', border: '1px solid #e5e7eb' }),
}
}
export default function IncidentFormPage({
incForm, setIncForm, organizations, groupCompanies: _gc = [], managements, departments, branches,
incidentTypes, incidentClasses, incidentFields, incidentStatuses: _incidentStatuses, users, userRoleMap = {}, formStatus, viewMode, canModify, onEdit, onDelete, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile,
userRole, delegatedFunctions = [], delegatedOrgs = {}, onSubmit, onBack, onNavigate, onLogout, user, onRefresh
}: IncidentFormPageProps) {
const tl = useT()
const set = (patch: Partial<IncidentForm>) => setIncForm({ ...incForm, ...patch })
const di: React.CSSProperties = viewMode ? { background: '#f9f9f9' } : {}
const managerUsers = usersWithRole(users, userRoleMap, 'SB Manager', incForm.organization)
const [wfStatus, setWfStatus] = useState('')
const [wfLoading, setWfLoading] = useState(false)
const title = incForm.name
? (viewMode ? `${tl.formTitle.viewIncident}${incForm.number || incForm.name}` : tl.formTitle.editIncident)
: tl.formTitle.newIncident
const filteredMgmts = filterOrgItems(managements, { organization: incForm.organization })
const filteredDepts = filterOrgItems(departments, { organization: incForm.organization, management: incForm.management })
const filteredBranches = filterOrgItems(branches, { organization: incForm.organization, management: incForm.management, department: incForm.department })
const mgmtItems = activeItems(filteredMgmts, incForm.management).map(m => ({ name: m.name, label: m.name1 || m.name }))
const deptItems = activeItems(filteredDepts, incForm.department).map(d => ({ name: d.name, label: d.department_name || d.name }))
const branchItems = activeItems(filteredBranches, incForm.branch).map(b => ({ name: b.name, label: b.branch || b.name }))
const typeItems = activeItems(incidentTypes, incForm.type).map(t => ({ name: t.name, label: t.name1 || t.name }))
const classItems = activeItems(incidentClasses, incForm.incident_class).map(c => ({ name: c.name, label: c.name1 || c.name }))
const fieldItems = activeItems(incidentFields, incForm.incident_field).map(f => ({ name: f.name, label: f.name1 || f.name }))
const wfState = incForm.workflow_state || 'New'
const isEmployee = userRole === 'Employee'
const isAuditorAdmin = userRole === 'Auditor' || userRole === 'Admin'
const isManager = userRole === 'Manager'
const hasIncidentDelegation = isManager && delegatedFunctions.includes('incident_initial_evaluation') &&
(!delegatedOrgs['incident_initial_evaluation'] || (delegatedOrgs['incident_initial_evaluation'] || []).includes(incForm.organization))
const canEvaluateIncident = isAuditorAdmin || hasIncidentDelegation
type IncAction = { label: string; action: string; color: string }
const availableActions: IncAction[] = (() => {
if (!incForm.name) return []
if (wfState === 'New')
return [{ label: tl.wf.submit, action: 'Submit', color: '#2563eb' }]
if (wfState === 'Initial Evaluation' && canEvaluateIncident)
return [
{ label: tl.wf.assignToManager, action: 'Assign to Manager', color: '#d97706' },
{ label: tl.wf.reject, action: 'Reject', color: '#dc2626' },
]
if (wfState === 'Accept & Under Progress' && (isManager || isAuditorAdmin))
return [{ label: tl.wf.submitSolution, action: 'Submit Solution', color: '#059669' }]
if (wfState === 'working on resolution' && (isManager || isAuditorAdmin))
return [{ label: tl.wf.resolutionComplete, action: 'Resolution complete', color: '#166534' }]
return []
})()
async function doAction(action: string) {
if (!incForm.name) return
setWfLoading(true); setWfStatus('')
try {
const extra: Record<string, string> = {}
if (action === 'Assign to Manager') {
if (incForm.assigned_to) extra.assigned_to = incForm.assigned_to
// save all triage fields the Auditor filled in
if (incForm.priority) extra.priority = incForm.priority
if (incForm.economy_class) extra.economy_class = incForm.economy_class
if (incForm.entity_scope) extra.entity_scope = incForm.entity_scope
if (incForm.hses_field) extra.hses_field = incForm.hses_field
extra.product_relation = String(incForm.product_relation ?? 0)
extra.repeated = String(incForm.repeated ?? 0)
}
if (action === 'Submit Solution') {
if (incForm.solution) extra.solution = incForm.solution
if (incForm.summary) extra.summary = incForm.summary
if (incForm.cost) extra.cost = String(incForm.cost)
}
if ((action === 'Resolution complete' || action === 'Submit Solution') && incForm.auditor_comment) {
extra.auditor_comment = incForm.auditor_comment
}
await applyIncidentWorkflow(incForm.name, action, JSON.stringify(extra))
setWfStatus('Done')
onRefresh?.()
} catch (e: any) {
setWfStatus(e.message || 'Error')
} finally {
setWfLoading(false)
}
}
const assignedManagerName = managerUsers.find(u => u.name === incForm.assigned_to)?.full_name || incForm.assigned_to
const rightPanel = incForm.name ? (
<div style={{ width: 240, display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Workflow Actions Card */}
<div style={{ background: 'white', borderRadius: 10, padding: 14, border: '1px solid #e2e8f0', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#1e293b', marginBottom: 10 }}>
{tl.wf.workflow}
</div>
{/* Current State Subbox */}
<div style={{
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 8,
padding: '10px 12px',
marginBottom: 12
}}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.4px', marginBottom: 4 }}>
Current State
</div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#1e293b' }}>
{wfState}
</div>
</div>
{wfState === 'Initial Evaluation' && canEvaluateIncident && (
<div style={{ marginBottom: 10 }}>
<label style={lbl}>{tl.wf.assignToManager}</label>
<select style={inp} value={incForm.assigned_to || ''} onChange={e => set({ assigned_to: e.target.value })}>
<option value="">{tl.field.selectMgr}</option>
{managerUsers.map(u => <option key={u.name} value={u.name}>{u.full_name || u.name}</option>)}
</select>
</div>
)}
{(wfState === 'Accept & Under Progress' || wfState === 'working on resolution') && (
<div style={{ marginBottom: 10 }}>
<label style={lbl}>{tl.field.solution}</label>
<textarea style={{ ...inp, height: 75, resize: 'vertical' }}
value={incForm.solution || ''}
onChange={e => set({ solution: e.target.value })}
placeholder={tl.field.solutionPlaceholder} />
<label style={{ ...lbl, marginTop: 6 }}>{tl.field.actualCost}</label>
<input type="number" style={inp} value={incForm.cost || ''} onChange={e => set({ cost: parseFloat(e.target.value) || 0 })} placeholder="0.00" />
</div>
)}
{wfState === 'working on resolution' && isAuditorAdmin && (
<div style={{ marginBottom: 10 }}>
<label style={lbl}>{tl.field.auditorComment}</label>
<textarea style={{ ...inp, height: 60, resize: 'vertical' }}
value={incForm.auditor_comment || ''}
onChange={e => set({ auditor_comment: e.target.value })}
placeholder={tl.field.commentPlaceholder} />
</div>
)}
{availableActions.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{availableActions.map(t => (
<button key={t.action} onClick={() => doAction(t.action)} disabled={wfLoading}
style={{
width: '100%',
padding: '8px 12px',
borderRadius: 6,
border: 'none',
background: t.color,
color: 'white',
fontWeight: 600,
fontSize: 12,
cursor: wfLoading ? 'not-allowed' : 'pointer',
opacity: wfLoading ? 0.7 : 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6
}}>
<span>{wfLoading ? tl.wf.applying : t.label}</span>
</button>
))}
</div>
) : (
<div style={{ fontSize: 11, color: '#94a3b8', fontStyle: 'italic' }}>{tl.wf.noActions}</div>
)}
{wfStatus && <p style={{ marginTop: 8, fontSize: 12, color: wfStatus === 'Done' ? colors.green : colors.red }}>{wfStatus}</p>}
</div>
{/* Status Summary Card */}
<StatusSummaryCard
priority={incForm.priority}
status={wfState}
assignedTo={assignedManagerName || (incForm.assigned_to ? incForm.assigned_to : undefined)}
createdDate={incForm.date}
modifiedDate={(incForm as any).modified}
/>
<CommentSection doctype={DOCTYPES.incidents} docname={incForm.name} currentUserName={user.name} />
<ActivityLog doctype={DOCTYPES.incidents} docname={incForm.name} docCreation={(incForm as any).creation} docOwner={(incForm as any).owner} />
{canModify && onDelete && !incForm.custom_is_deleted && (
<DeleteRecordCard
title="Delete Request"
btnText={tl.delete || 'Delete'}
onDelete={() => onDelete(incForm.name)}
/>
)}
</div>
) : null
return (
<FormPageLayout
title={title}
activePage="incidents"
user={user} formStatus={formStatus}
statusBadge={<span style={incStateBadge(wfState)}>{wfState}</span>}
buttonText={tl.saveIncident} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
rightPanel={rightPanel}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{incForm.custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={incForm.custom_deleted_by}
deletedOn={incForm.custom_deleted_on}
reason={incForm.custom_deleted_reason}
onRestore={onRestore ? () => onRestore(incForm.name) : undefined}
onPermanentDelete={onPermanentDelete ? () => onPermanentDelete(incForm.name) : undefined}
/>
)}
{/* Section 1: Incident Information */}
<FormCard title="Incident Information">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
{incForm.name && (
<div>
<label style={lbl}>{tl.field.number}</label>
<input style={{ ...inp, background: '#f8fafc', color: '#475569', fontWeight: 600 }} value={incForm.number} readOnly />
</div>
)}
<div>
<label style={lbl}>{tl.field.organization} <span style={{ color: '#e74c3c' }}>*</span></label>
<OrgSelect
value={incForm.organization}
onChange={v => {
const orgCo = organizations.find(o => o.name === v)?.group_company
set({ organization: v, ...(user.custom_group_company ? {} : { group_company: orgCo || incForm.group_company }) })
}}
organizations={organizations}
disabled={viewMode || !!user.custom_organization}
userRole={userRole}
onQuickCreateSuccess={onRefresh}
/>
</div>
<div>
<label style={lbl}>{tl.field.title}</label>
<input style={{ ...inp, ...di }} value={incForm.title} disabled={viewMode} onChange={e => set({ title: e.target.value })} />
</div>
<div>
<label style={lbl}>{tl.field.management}</label>
<LinkSelect
value={incForm.management}
onChange={v => set({ management: v })}
items={mgmtItems}
placeholder={tl.field.select}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.managements}
extraFields={{ organization: incForm.organization }}
onQuickCreateSuccess={onRefresh}
/>
</div>
<div>
<label style={lbl}>{tl.field.department}</label>
<LinkSelect
value={incForm.department}
onChange={v => set({ department: v })}
items={deptItems}
placeholder={tl.field.select}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.departments}
extraFields={{ organization: incForm.organization, management: incForm.management }}
onQuickCreateSuccess={onRefresh}
/>
</div>
<div>
<label style={lbl}>{tl.field.section}</label>
<input style={{ ...inp, ...di }} value={incForm.section_field || ''} disabled={viewMode} onChange={e => set({ section_field: e.target.value })} />
</div>
<div>
<label style={lbl}>{tl.field.branch}</label>
<LinkSelect
value={incForm.branch}
onChange={v => set({ branch: v })}
items={branchItems}
placeholder={tl.field.select}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.branches}
extraFields={{ organization: incForm.organization, management: incForm.management, department: incForm.department }}
onQuickCreateSuccess={onRefresh}
/>
</div>
<div>
<label style={lbl}>{tl.field.eventCategory}</label>
<select
style={{ ...inp, ...di }}
value={incForm.event_category || ''}
disabled={viewMode}
onChange={e => set({ event_category: e.target.value })}
>
<option value="">{tl.field.select}</option>
{incForm.event_category && !['Complaint', 'Accident', 'Violent'].includes(incForm.event_category) && (
<option value={incForm.event_category}>{incForm.event_category}</option>
)}
<option value="Complaint">Complaint</option>
<option value="Accident">Accident</option>
<option value="Violent">Violent</option>
</select>
</div>
<div>
<label style={lbl}>{tl.field.type}</label>
<LinkSelect
value={incForm.type}
onChange={v => set({ type: v })}
items={typeItems}
placeholder={tl.field.selectType}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.incidentTypes}
onQuickCreateSuccess={onRefresh}
/>
</div>
<div>
<label style={lbl}>{tl.field.incidentClass}</label>
<LinkSelect
value={incForm.incident_class}
onChange={v => set({ incident_class: v })}
items={classItems}
placeholder={tl.field.selectClass}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.incidentClass}
onQuickCreateSuccess={onRefresh}
/>
</div>
<div>
<label style={lbl}>{tl.field.incidentField}</label>
<LinkSelect
value={incForm.incident_field}
onChange={v => set({ incident_field: v })}
items={fieldItems}
placeholder={tl.field.selectField}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.incidentField}
onQuickCreateSuccess={onRefresh}
/>
</div>
<div>
<label style={lbl}>{tl.field.hsesField}</label>
<input style={{ ...inp, ...di }} value={incForm.hses_field || ''} disabled={viewMode} onChange={e => set({ hses_field: e.target.value })} placeholder="Safety, Health, Environment…" />
</div>
</div>
</FormCard>
{/* Section 2: Description & Timing */}
<FormCard title="Incident Details & Timing">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16, marginBottom: 14 }}>
<div>
<label style={lbl}>{tl.field.date}</label>
<DateInput value={incForm.date} onChange={v => set({ date: v })} disabled style={{ ...inp, background: '#f8fafc' }} />
</div>
<div>
<label style={lbl}>{tl.field.time}</label>
<input type="time" style={{ ...inp, background: '#f8fafc' }} value={incForm.time} disabled readOnly />
</div>
<div>
<label style={lbl}>{tl.field.assignedTo}</label>
{viewMode || userRole === 'Employee' ? (
<input
style={{ ...inp, background: '#f8fafc', fontWeight: 600, color: '#334155' }}
value={assignedManagerName || incForm.assigned_to || '—'}
readOnly
disabled
/>
) : (
<LinkSelect
value={incForm.assigned_to || ''}
onChange={v => set({ assigned_to: v })}
items={managerUsers.map(u => ({ name: u.name, label: u.full_name || u.name }))}
placeholder={tl.field.select}
/>
)}
</div>
</div>
<div>
<label style={lbl}>{tl.field.description}</label>
<textarea
style={{ ...inp, ...di, height: 100, resize: 'vertical' }}
value={incForm.description}
disabled={viewMode}
onChange={e => set({ description: e.target.value })}
placeholder="Detailed description of the incident..."
/>
</div>
</FormCard>
{/* Section 3: Auditor Classification & Review (Hidden from Employee) */}
{!isEmployee && (
<FormCard title={tl.steps.auditorTriage}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 12 }}>
<div>
<label style={lbl}>{tl.field.priority}</label>
<select style={{ ...inp, ...(viewMode || !canEvaluateIncident ? { background: '#f8fafc' } : {}) }}
value={incForm.priority || ''} disabled={viewMode || !canEvaluateIncident}
onChange={e => set({ priority: e.target.value })}>
<option value="">{tl.field.select}</option>
<option value="High">{tl.field.high}</option>
<option value="Normal">{tl.field.normal}</option>
</select>
</div>
<div>
<label style={lbl}>{tl.field.economyClass}</label>
<select style={{ ...inp, ...(viewMode || !canEvaluateIncident ? { background: '#f8fafc' } : {}) }}
value={incForm.economy_class || ''} disabled={viewMode || !canEvaluateIncident}
onChange={e => set({ economy_class: e.target.value })}>
<option value="">{tl.field.select}</option>
<option value="Expenses">{tl.field.expenses}</option>
<option value="Capital">{tl.field.capital}</option>
<option value="Earn">{tl.field.earn}</option>
<option value="Pay/Loss">{tl.field.payLoss}</option>
</select>
</div>
<div>
<label style={lbl}>{tl.field.entityScope}</label>
<select style={{ ...inp, ...(viewMode || !canEvaluateIncident ? { background: '#f8fafc' } : {}) }}
value={incForm.entity_scope || ''} disabled={viewMode || !canEvaluateIncident}
onChange={e => set({ entity_scope: e.target.value })}>
<option value="">{tl.field.select}</option>
<option value="Upstream">{tl.field.upstream}</option>
<option value="On-stream">{tl.field.onstream}</option>
<option value="Downstream">{tl.field.downstream}</option>
</select>
</div>
</div>
<div style={{ display: 'flex', gap: 20, marginTop: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
<input type="checkbox" checked={!!incForm.product_relation} disabled={viewMode || !canEvaluateIncident}
onChange={e => set({ product_relation: e.target.checked ? 1 : 0 })} /> {tl.field.productRelation}
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
<input type="checkbox" checked={!!incForm.repeated} disabled={viewMode || !canEvaluateIncident}
onChange={e => set({ repeated: e.target.checked ? 1 : 0 })} /> {tl.field.repeatedEvent}
</label>
</div>
</FormCard>
)}
{/* Section 4: Manager Solution & Resolution (Hidden from Employee) */}
{!isEmployee && (
<FormCard title={tl.steps.managerSolution}>
<div style={{ marginBottom: 12 }}>
<label style={lbl}>{tl.field.solution}</label>
<textarea style={{ ...inp, height: 90, resize: 'vertical',
...(viewMode && !isManager && !isAuditorAdmin ? { background: '#f8fafc' } : {}) }}
value={incForm.solution || ''} disabled={viewMode && !isManager && !isAuditorAdmin}
onChange={e => set({ solution: e.target.value })} placeholder={tl.field.solutionPlaceholder} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16, marginBottom: 12 }}>
<div>
<label style={lbl}>{tl.field.actualCost}</label>
<input type="number" style={{ ...inp,
...(viewMode && !isManager && !isAuditorAdmin ? { background: '#f8fafc' } : {}) }}
value={incForm.cost || ''} disabled={viewMode && !isManager && !isAuditorAdmin}
onChange={e => set({ cost: parseFloat(e.target.value) || 0 })} placeholder="0.00" />
</div>
<div>
<label style={lbl}>{tl.field.summary}</label>
<input style={{ ...inp, ...(viewMode && !isManager && !isAuditorAdmin ? { background: '#f8fafc' } : {}) }}
value={incForm.summary || ''} disabled={viewMode && !isManager && !isAuditorAdmin}
onChange={e => set({ summary: e.target.value })} placeholder="Brief summary" />
</div>
</div>
<div>
<label style={lbl}>{tl.field.auditorComment}</label>
<textarea style={{ ...inp, height: 70, resize: 'vertical',
...(viewMode && !isAuditorAdmin ? { background: '#f8fafc' } : {}) }}
value={incForm.auditor_comment || ''} disabled={viewMode && !isAuditorAdmin}
onChange={e => set({ auditor_comment: e.target.value })} placeholder={tl.field.commentPlaceholder} />
</div>
</FormCard>
)}
</FormPageLayout>
)
}

View File

@ -0,0 +1,108 @@
import { useT } from '../i18n'
import { Dispatch, SetStateAction } from 'react'
import { IncidentStatus, User, Page } from '../types'
import { inp, lbl, chk, FormCard, DeletedBanner } from '../components/common'
import DateInput from '../components/DateInput'
import FormPageLayout from '../components/FormPageLayout'
interface Props {
form: IncidentStatus
setForm: Dispatch<SetStateAction<IncidentStatus>>
formStatus: string
viewMode?: boolean
onEdit?: () => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
export default function IncidentStatusFormPage({ form, setForm, formStatus, viewMode, onEdit, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile, onSubmit, onBack, onNavigate, onLogout, user }: Props) {
const t = useT()
const set = (patch: Partial<IncidentStatus>) => setForm(f => ({ ...f, ...patch }))
const di: React.CSSProperties = viewMode ? { background: '#f8fafc' } : {}
const title = form.name
? (viewMode ? `Incident Status: ${form.name1 || form.name}` : 'Edit Incident Status')
: 'New Incident Status'
return (
<FormPageLayout
title={title} activePage="incident-statuses"
user={user} formStatus={formStatus}
buttonText={t.saveName} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{(form as any).custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={(form as any).custom_deleted_by}
deletedOn={(form as any).custom_deleted_on}
reason={(form as any).custom_deleted_reason}
onRestore={onRestore && form.name ? () => onRestore(form.name) : undefined}
onPermanentDelete={onPermanentDelete && form.name ? () => onPermanentDelete(form.name) : undefined}
/>
)}
<FormCard title="Incident Status Details">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16, marginBottom: 14 }}>
<div>
<label style={lbl}>{t.field.name1} <span style={{ color: '#e74c3c' }}>*</span></label>
<input
style={{ ...inp, ...di }}
value={form.name1}
disabled={viewMode || !!form.name}
onChange={e => set({ name1: e.target.value })}
placeholder="Incident status name"
required
/>
</div>
<div>
<label style={lbl}>{t.field.creationDate}</label>
<DateInput
value={form.creation_date || ''}
onChange={v => set({ creation_date: v })}
disabled={viewMode}
style={{ ...inp, ...di }}
/>
</div>
<div>
<label style={lbl}>{t.field.status}</label>
<select
style={{ ...inp, ...di }}
value={form.status || 'Active'}
disabled={viewMode}
onChange={e => set({ status: e.target.value })}
>
<option value="Active">{t.field.statusActive}</option>
<option value="Inactive">{t.field.statusInactive}</option>
</select>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
id="notify_manager"
checked={!!form.notify_manager}
disabled={viewMode}
onChange={e => set({ notify_manager: e.target.checked ? 1 : 0 })}
style={chk}
/>
<label htmlFor="notify_manager" style={{ fontSize: 13, cursor: viewMode ? 'default' : 'pointer' }}>
{t.field.notifyManager}
</label>
</div>
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,141 @@
import { useEffect, useMemo, useState } from 'react'
import { IncidentStatus } from '../types'
import { RowActions } from '../components/common'
import StatCard from '../components/StatCard'
import FilterBar from '../components/FilterBar'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { colors } from '../constants'
import { useT } from '../i18n'
interface Props {
incidentStatuses: IncidentStatus[]
canModify?: boolean
onView?: (t: IncidentStatus) => void
onEdit?: (t: IncidentStatus) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: IncidentStatus[], page: IncidentStatus[]) => void
}
function fmtDate(d?: string) {
if (!d) return '—'
const dt = new Date(d)
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')}/${dt.getFullYear()}`
}
export default function IncidentStatusPage({ incidentStatuses: allIncidentStatuses, canModify, onView, onEdit, onDelete, onSelectionChange }: Props) {
const tl = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }
const incidentStatuses = useMemo(() => allIncidentStatuses.filter(is => {
const label = (is.name1 || is.name || '').toLowerCase()
if (search && !label.includes(search.toLowerCase())) return false
if (statusFilter !== 'all' && (is.status || 'Active') !== statusFilter) return false
return true
}), [allIncidentStatuses, search, statusFilter])
const [page, setPage] = useState(1)
useEffect(() => { setPage(1) }, [search, statusFilter])
const pageRows = incidentStatuses.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = allIncidentStatuses.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows])
return (
<>
<div style={{ display: 'flex', gap: 16 }}>
<StatCard label="Total" val={incidentStatuses.length} color={colors.red} />
</div>
<FilterBar filters={[
{ type: 'search', label: 'Search', value: search, onChange: setSearch, placeholder: 'Search by name…' },
{ type: 'select', label: 'Status', value: statusFilter, onChange: setStatusFilter, options: [
{ value: 'all', label: 'All' }, { value: 'Active', label: tl.field.statusActive }, { value: 'Inactive', label: tl.field.statusInactive },
] },
]} />
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8f9fa', position: 'sticky', top: 0 }}>
<th style={{ width: 40, padding: '10px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={th}>{tl.col.name}</th>
<th style={th}>{tl.field.notifyManager}</th>
<th style={th}>{tl.field.creationDate}</th>
<th style={th}>{tl.field.status}</th>
<th style={th}>{tl.col.actions}</th>
</tr>
</thead>
<tbody>
{incidentStatuses.length === 0
? <tr><td colSpan={6} style={{ padding: 40, textAlign: 'center', color: '#aaa' }}>{tl.noData}</td></tr>
: pageRows.map((is, i) => (
<tr
key={is.name}
className="app-table-row"
onClick={() => onView?.(is)}
style={{
borderTop: '1px solid #f0f0f0',
background: selectedIds.has(is.name) ? '#eff6ff' : ((is as any).custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer'
}}
>
<td style={{ width: 40, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(is.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(is.name) ? next.delete(is.name) : next.add(is.name)
setSelectedIds(next)
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{ is.name1 || is.name || '—'}</td>
<td style={{ padding: '10px 14px' }}>{ is.notify_manager ? tl.field.statusActive : '—'}</td>
<td style={{ padding: '10px 14px', color: '#666' }}>{fmtDate(is.creation_date)}</td>
<td style={{ padding: '10px 14px' }}>
{(is as any).custom_is_deleted ? (
<span style={{ background: '#fee2e2', color: '#991b1b', padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 700 }}>
{tl.deletedBadge || 'DELETED'}
</span>
) : (
<span style={{ color: is.status === 'Inactive' ? '#e74c3c' : '#16a34a', fontWeight: 600 }}>
{ is.status === 'Inactive' ? tl.field.statusInactive : tl.field.statusActive}
</span>
)}
</td>
<td style={{ padding: '8px 14px' }} onClick={e => e.stopPropagation()}>
<RowActions onView={() => onView?.(is)} onEdit={canModify && onEdit && !(is as any).custom_is_deleted ? () => onEdit(is) : undefined} onDelete={canModify && onDelete && !(is as any).custom_is_deleted ? () => onDelete(is.name) : undefined} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={incidentStatuses.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,94 @@
import { useT } from '../i18n'
import { Dispatch, SetStateAction } from 'react'
import { IncidentType, User, Page } from '../types'
import { inp, lbl, FormCard, DeletedBanner } from '../components/common'
import DateInput from '../components/DateInput'
import FormPageLayout from '../components/FormPageLayout'
interface Props {
form: IncidentType
setForm: Dispatch<SetStateAction<IncidentType>>
formStatus: string
viewMode?: boolean
onEdit?: () => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
export default function IncidentTypeFormPage({ form, setForm, formStatus, viewMode, onEdit, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile, onSubmit, onBack, onNavigate, onLogout, user }: Props) {
const t = useT()
const set = (patch: Partial<IncidentType>) => setForm(f => ({ ...f, ...patch }))
const di: React.CSSProperties = viewMode ? { background: '#f8fafc' } : {}
const title = form.name
? (viewMode ? `Incident Type: ${form.name1 || form.name}` : 'Edit Incident Type')
: 'New Incident Type'
return (
<FormPageLayout
title={title} activePage="incident-types"
user={user} formStatus={formStatus}
buttonText={t.saveName} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{(form as any).custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={(form as any).custom_deleted_by}
deletedOn={(form as any).custom_deleted_on}
reason={(form as any).custom_deleted_reason}
onRestore={onRestore && form.name ? () => onRestore(form.name) : undefined}
onPermanentDelete={onPermanentDelete && form.name ? () => onPermanentDelete(form.name) : undefined}
/>
)}
<FormCard title="Incident Type Details">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
<div>
<label style={lbl}>{t.field.name1} <span style={{ color: '#e74c3c' }}>*</span></label>
<input
style={{ ...inp, ...di }}
value={form.name1}
disabled={viewMode || !!form.name}
onChange={e => set({ name1: e.target.value })}
placeholder="Incident type name"
required
/>
</div>
<div>
<label style={lbl}>{t.field.creationDate}</label>
<DateInput
value={form.creation_date || ''}
onChange={v => set({ creation_date: v })}
disabled={viewMode}
style={{ ...inp, ...di }}
/>
</div>
<div>
<label style={lbl}>{t.field.status}</label>
<select
style={{ ...inp, ...di }}
value={form.status || 'Active'}
disabled={viewMode}
onChange={e => set({ status: e.target.value })}
>
<option value="Active">{t.field.statusActive}</option>
<option value="Inactive">{t.field.statusInactive}</option>
</select>
</div>
</div>
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from 'react'
import { IncidentType } from '../types'
import { RowActions } from '../components/common'
import StatCard from '../components/StatCard'
import FilterBar from '../components/FilterBar'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { colors } from '../constants'
import { useT } from '../i18n'
interface Props {
incidentTypes: IncidentType[]
canModify?: boolean
onView?: (t: IncidentType) => void
onEdit?: (t: IncidentType) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: IncidentType[], page: IncidentType[]) => void
}
function fmtDate(d?: string) {
if (!d) return '—'
const dt = new Date(d)
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')}/${dt.getFullYear()}`
}
export default function IncidentTypePage({ incidentTypes: allIncidentTypes, canModify, onView, onEdit, onDelete, onSelectionChange }: Props) {
const t = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }
const incidentTypes = useMemo(() => allIncidentTypes.filter(it => {
const label = (it.name1 || it.name || '').toLowerCase()
if (search && !label.includes(search.toLowerCase())) return false
if (statusFilter !== 'all' && (it.status || 'Active') !== statusFilter) return false
return true
}), [allIncidentTypes, search, statusFilter])
const [page, setPage] = useState(1)
useEffect(() => { setPage(1) }, [search, statusFilter])
const pageRows = incidentTypes.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = allIncidentTypes.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows])
return (
<>
<div style={{ display: 'flex', gap: 16 }}>
<StatCard label="Total" val={incidentTypes.length} color={colors.red} />
</div>
<FilterBar filters={[
{ type: 'search', label: 'Search', value: search, onChange: setSearch, placeholder: 'Search by name…' },
{ type: 'select', label: 'Status', value: statusFilter, onChange: setStatusFilter, options: [
{ value: 'all', label: 'All' }, { value: 'Active', label: t.field.statusActive }, { value: 'Inactive', label: t.field.statusInactive },
] },
]} />
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8f9fa', position: 'sticky', top: 0 }}>
<th style={{ width: 40, padding: '10px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={th}>{t.col.name}</th>
<th style={th}>{t.field.creationDate}</th>
<th style={th}>{t.field.status}</th>
<th style={th}>{t.col.actions}</th>
</tr>
</thead>
<tbody>
{incidentTypes.length === 0
? <tr><td colSpan={5} style={{ padding: 40, textAlign: 'center', color: '#aaa' }}>{t.noData}</td></tr>
: pageRows.map((it, i) => (
<tr
key={it.name}
className="app-table-row"
onClick={() => onView?.(it)}
style={{
borderTop: '1px solid #f0f0f0',
background: selectedIds.has(it.name) ? '#eff6ff' : ((it as any).custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer'
}}
>
<td style={{ width: 40, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(it.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(it.name) ? next.delete(it.name) : next.add(it.name)
setSelectedIds(next)
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{ it.name1 || it.name || '—'}</td>
<td style={{ padding: '10px 14px', color: '#666' }}>{fmtDate(it.creation_date)}</td>
<td style={{ padding: '10px 14px' }}>
{(it as any).custom_is_deleted ? (
<span style={{ background: '#fee2e2', color: '#991b1b', padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 700 }}>
{t.deletedBadge || 'DELETED'}
</span>
) : (
<span style={{ color: it.status === 'Inactive' ? '#e74c3c' : '#16a34a', fontWeight: 600 }}>
{ it.status === 'Inactive' ? t.field.statusInactive : t.field.statusActive}
</span>
)}
</td>
<td style={{ padding: '8px 14px' }} onClick={e => e.stopPropagation()}>
<RowActions onView={() => onView?.(it)} onEdit={canModify && onEdit && !(it as any).custom_is_deleted ? () => onEdit(it) : undefined} onDelete={canModify && onDelete && !(it as any).custom_is_deleted ? () => onDelete(it.name) : undefined} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={incidentTypes.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,189 @@
import { useEffect, useState } from 'react'
import { Incident, Organization, Department, Branch, IncidentStatus } from '../types'
import { typeBadge, statusBadge, RowActions, isWorkflowClosed } from '../components/common'
import FilterSection, { fLbl, fInp } from '../components/FilterSection'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { useT } from '../i18n'
interface Props {
incidents: Incident[]
organizations: Organization[]
departments: Department[]
branches: Branch[]
incidentStatuses: IncidentStatus[]
initialOrg?: string
initialStatus?: string
canModify?: boolean
userRole?: string
onView?: (i: Incident) => void
onEdit?: (i: Incident) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: Incident[], page: Incident[]) => void
}
export default function IncidentsPage({
incidents,
organizations,
departments,
branches,
incidentStatuses,
initialOrg,
initialStatus,
canModify,
userRole,
onView,
onEdit,
onDelete,
onSelectionChange,
}: Props) {
const t = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [f, setF] = useState({
search: '',
organization: initialOrg || '',
department: '',
branch: '',
status: initialStatus || '',
})
const set = (k: string, v: string) => setF(p => ({ ...p, [k]: v }))
const [page, setPage] = useState(1)
const rows = incidents.filter(inc => {
if (f.search && !`${inc.number} ${inc.title}`.toLowerCase().includes(f.search.toLowerCase())) return false
if (f.organization && inc.organization !== f.organization) return false
if (f.department && inc.department !== f.department) return false
if (f.branch && inc.branch !== f.branch) return false
if (f.status && inc.status !== f.status) return false
return true
})
useEffect(() => { setPage(1) }, [f])
const pageRows = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = incidents.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows, incidents])
return (
<>
<FilterSection>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '10px 14px' }}>
<div>
<label style={fLbl}>{t.filter.numberTitle}</label>
<input style={fInp} value={f.search} onChange={e => set('search', e.target.value)} placeholder={t.filter.searchPlaceholder} />
</div>
<div>
<label style={fLbl}>{t.filter.organization}</label>
<select style={fInp} value={f.organization} onChange={e => set('organization', e.target.value)}>
<option value="">{t.filter.allOrgs}</option>
{organizations.map(o => <option key={o.name} value={o.name}>{o.organization_name || o.name}</option>)}
</select>
</div>
<div>
<label style={fLbl}>{t.filter.department}</label>
<select style={fInp} value={f.department} onChange={e => set('department', e.target.value)}>
<option value="">{t.filter.allDepts}</option>
{departments.map(d => <option key={d.name} value={d.name}>{d.department_name || d.name}</option>)}
</select>
</div>
<div>
<label style={fLbl}>{t.filter.branch}</label>
<select style={fInp} value={f.branch} onChange={e => set('branch', e.target.value)}>
<option value="">{t.filter.allBranches}</option>
{branches.map(b => <option key={b.name} value={b.name}>{b.branch || b.name}</option>)}
</select>
</div>
<div>
<label style={fLbl}>{t.filter.status}</label>
<select style={fInp} value={f.status} onChange={e => set('status', e.target.value)}>
<option value="">{t.filter.allStatuses}</option>
{incidentStatuses.map(s => <option key={s.name} value={s.name}>{s.name1 || s.name}</option>)}
</select>
</div>
</div>
</FilterSection>
<div style={{ background: 'white', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.06)', overflow: 'hidden', border: '1px solid #f1f5f9' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f8fafc', color: '#64748b', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>
<th style={{ width: 44, padding: '12px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.number}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.title}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.organization}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.type}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.status}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.actions}</th>
</tr>
</thead>
<tbody>
{rows.length === 0
? <tr><td colSpan={7} style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>{t.noIncidents}</td></tr>
: pageRows.map((inc, i) => (
<tr
key={inc.name}
className="app-table-row"
onClick={() => onView?.(inc)}
style={{
borderTop: '1px solid #f1f5f9',
background: selectedIds.has(inc.name) ? '#eff6ff' : (inc.custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
>
<td style={{ width: 44, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(inc.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(inc.name) ? next.delete(inc.name) : next.add(inc.name)
setSelectedIds(next)
}}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '12px 16px', color: '#64748b', fontSize: 12, fontWeight: 500 }}>{inc.number || '—'}</td>
<td style={{ padding: '12px 16px', fontWeight: 600, color: '#1e293b' }}>{inc.title || inc.name || '—'}</td>
<td style={{ padding: '12px 16px', color: '#475569' }}>{inc.organization || '—'}</td>
<td style={{ padding: '12px 16px' }}>{inc.type ? typeBadge(inc.type) : '—'}</td>
<td style={{ padding: '12px 16px' }}>{inc.custom_is_deleted ? statusBadge(t.deletedBadge || 'DELETED') : (inc.status ? statusBadge(inc.status) : '—')}</td>
<td style={{ padding: '10px 16px' }} onClick={e => e.stopPropagation()}>
<RowActions
onView={() => onView?.(inc)}
onEdit={
canModify && onEdit && !inc.custom_is_deleted && !isWorkflowClosed('incident', inc.workflow_state) && !(userRole === 'Employee' && inc.workflow_state && inc.workflow_state !== 'New')
? () => onEdit(inc)
: undefined
}
onDelete={canModify && onDelete && !inc.custom_is_deleted ? () => onDelete(inc.name) : undefined}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={rows.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,131 @@
import { Dispatch, SetStateAction } from 'react'
import { useT } from '../i18n'
import { SBKpiInput, Organization, User, Page, SBGroupCompany } from '../types'
import { inp, lbl, FormCard, DeletedBanner } from '../components/common'
import FormPageLayout from '../components/FormPageLayout'
import OrgSelect from '../components/OrgSelect'
interface KpiInputFormPageProps {
form: SBKpiInput
setForm: Dispatch<SetStateAction<SBKpiInput>>
organizations: Organization[]
groupCompanies?: SBGroupCompany[]
formStatus: string
viewMode?: boolean
onEdit?: () => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
export default function KpiInputFormPage({
form, setForm, organizations, groupCompanies: _gc = [], formStatus, viewMode, onEdit, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile,
onSubmit, onBack, onNavigate, onLogout, user
}: KpiInputFormPageProps) {
const t = useT()
const set = (patch: Partial<SBKpiInput>) => setForm(f => ({ ...f, ...patch }))
const di: React.CSSProperties = viewMode ? { background: '#f8fafc' } : {}
const num = (key: keyof SBKpiInput) => (e: React.ChangeEvent<HTMLInputElement>) =>
set({ [key]: parseFloat(e.target.value) || 0 } as any)
const title = form.name
? (viewMode ? `${t.formTitle.editKpi}: ${form.period || form.name}` : t.formTitle.editKpi)
: t.formTitle.newKpi
return (
<FormPageLayout
title={title} activePage="kpi-input"
user={user} formStatus={formStatus}
buttonText={t.saveKpi} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{(form as any).custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={(form as any).custom_deleted_by}
deletedOn={(form as any).custom_deleted_on}
reason={(form as any).custom_deleted_reason}
onRestore={onRestore && form.name ? () => onRestore(form.name) : undefined}
onPermanentDelete={onPermanentDelete && form.name ? () => onPermanentDelete(form.name) : undefined}
/>
)}
<FormCard title="Period & Organization">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 16 }}>
<div>
<label style={lbl}>{t.field.organization} <span style={{ color: '#e74c3c' }}>*</span></label>
<OrgSelect
value={form.organization}
onChange={v => {
const orgCo = organizations.find(o => o.name === v)?.group_company
set({ organization: v, ...(user.custom_group_company ? {} : { group_company: orgCo || form.group_company }) })
}}
organizations={organizations}
disabled={viewMode || !!user.custom_organization}
userRole={user.role}
/>
</div>
<div>
<label style={lbl}>{t.col.period} <span style={{ color: '#e74c3c' }}>*</span></label>
<input style={{ ...inp, ...di }} value={form.period || ''} disabled={viewMode} onChange={e => set({ period: e.target.value })} placeholder="e.g. Q1 2026" />
</div>
</div>
</FormCard>
<FormCard title="Financial KPIs">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16 }}>
<div>
<label style={lbl}>{t.field.revenue_growth}</label>
<input type="number" step="0.01" style={{ ...inp, ...di }} value={form.revenue_growth || ''} disabled={viewMode} onChange={num('revenue_growth')} />
</div>
<div>
<label style={lbl}>{t.field.net_profit}</label>
<input type="number" step="0.01" style={{ ...inp, ...di }} value={form.net_profit || ''} disabled={viewMode} onChange={num('net_profit')} />
</div>
<div>
<label style={lbl}>{t.field.cash_flow}</label>
<input type="number" step="0.01" style={{ ...inp, ...di }} value={form.cash_flow || ''} disabled={viewMode} onChange={num('cash_flow')} />
</div>
</div>
</FormCard>
<FormCard title="Operational KPIs">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16 }}>
<div>
<label style={lbl}>{t.field.strategic_goal_pct}</label>
<input type="number" step="0.01" style={{ ...inp, ...di }} value={form.strategic_goal_pct || ''} disabled={viewMode} onChange={num('strategic_goal_pct')} />
</div>
<div>
<label style={lbl}>{t.field.customer_satisfaction}</label>
<input type="number" step="0.01" style={{ ...inp, ...di }} value={form.customer_satisfaction || ''} disabled={viewMode} onChange={num('customer_satisfaction')} />
</div>
<div>
<label style={lbl}>{t.field.employee_turnover_pct}</label>
<input type="number" step="0.01" style={{ ...inp, ...di }} value={form.employee_turnover_pct || ''} disabled={viewMode} onChange={num('employee_turnover_pct')} />
</div>
</div>
</FormCard>
<FormCard title="Safety & Compliance">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16 }}>
<div>
<label style={lbl}>{t.field.open_safety_incidents}</label>
<input type="number" min={0} style={{ ...inp, ...di }} value={form.open_safety_incidents || ''} disabled={viewMode} onChange={e => set({ open_safety_incidents: parseInt(e.target.value) || 0 })} />
</div>
<div>
<label style={lbl}>{t.field.compliance_score}</label>
<input type="number" step="0.01" style={{ ...inp, ...di }} value={form.compliance_score || ''} disabled={viewMode} onChange={num('compliance_score')} />
</div>
</div>
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,135 @@
import { useEffect, useMemo, useState } from 'react'
import { SBKpiInput } from '../types'
import { RowActions } from '../components/common'
import StatCard from '../components/StatCard'
import FilterBar from '../components/FilterBar'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { colors } from '../constants'
import { useT } from '../i18n'
interface KpiInputPageProps {
kpiInputs: SBKpiInput[]
canModify?: boolean
onView?: (k: SBKpiInput) => void
onEdit?: (k: SBKpiInput) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: SBKpiInput[], page: SBKpiInput[]) => void
}
export default function KpiInputPage({ kpiInputs: allKpiInputs, canModify, onView, onEdit, onDelete, onSelectionChange }: KpiInputPageProps) {
const t = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [orgFilter, setOrgFilter] = useState('')
const orgs = useMemo(() => [...new Set(allKpiInputs.map(k => k.organization).filter(Boolean))], [allKpiInputs])
const kpiInputs = useMemo(() => allKpiInputs.filter(k => {
const label = [k.organization, k.period].filter(Boolean).join(' ').toLowerCase()
if (search && !label.includes(search.toLowerCase())) return false
if (orgFilter && k.organization !== orgFilter) return false
return true
}), [allKpiInputs, search, orgFilter])
const [page, setPage] = useState(1)
useEffect(() => { setPage(1) }, [search, orgFilter])
const pageRows = kpiInputs.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = allKpiInputs.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows])
return (
<>
<div style={{ display: 'flex', gap: 16 }}>
<StatCard label="Total Records" val={kpiInputs.length} color={colors.blue} />
</div>
<FilterBar filters={[
{ type: 'search', label: 'Search', value: search, onChange: setSearch, placeholder: 'Organization or period…' },
{ type: 'select', label: t.col.organization, value: orgFilter, onChange: setOrgFilter, options: [
{ value: '', label: 'All Organizations' }, ...orgs.map(o => ({ value: o, label: o })),
] },
]} />
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8f9fa', position: 'sticky', top: 0 }}>
<th style={{ width: 40, padding: '10px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={{ padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }}>{t.col.organization}</th>
<th style={{ padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }}>{t.col.period}</th>
<th style={{ padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }}>{t.field.revenue_growth}</th>
<th style={{ padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }}>{t.field.net_profit}</th>
<th style={{ padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }}>{t.dash.customerSat}</th>
<th style={{ padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }}>{t.col.actions}</th>
</tr>
</thead>
<tbody>
{kpiInputs.length === 0
? <tr><td colSpan={7} style={{ padding: 40, textAlign: 'center', color: '#aaa' }}>{t.noData}</td></tr>
: pageRows.map((k, i) => (
<tr
key={k.name}
className="app-table-row"
onClick={() => onView?.(k)}
style={{
borderTop: '1px solid #f0f0f0',
background: selectedIds.has(k.name) ? '#eff6ff' : ((k as any).custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer'
}}
>
<td style={{ width: 40, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(k.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(k.name) ? next.delete(k.name) : next.add(k.name)
setSelectedIds(next)
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{k.organization || '—'}</td>
<td style={{ padding: '10px 14px' }}>{k.period || '—'}</td>
<td style={{ padding: '10px 14px' }}>{k.revenue_growth != null ? `${k.revenue_growth}%` : '—'}</td>
<td style={{ padding: '10px 14px' }}>{k.net_profit != null ? k.net_profit.toLocaleString() : '—'}</td>
<td style={{ padding: '10px 14px' }}>
{(k as any).custom_is_deleted ? (
<span style={{ background: '#fee2e2', color: '#991b1b', padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 700 }}>
{t.deletedBadge || 'DELETED'}
</span>
) : (
k.customer_satisfaction != null ? k.customer_satisfaction : '—'
)}
</td>
<td style={{ padding: '8px 14px' }} onClick={e => e.stopPropagation()}>
<RowActions onView={() => onView?.(k)} onEdit={canModify && onEdit && !(k as any).custom_is_deleted ? () => onEdit(k) : undefined} onDelete={canModify && onDelete && !(k as any).custom_is_deleted ? () => onDelete(k.name) : undefined} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={kpiInputs.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,249 @@
import { useState } from 'react'
import { User } from '../types'
import { loginApi, getUserRoles, getSessionUser } from '../api'
import { useT, toggleLang } from '../i18n'
import logoImg from '../assets/logo.png'
interface LoginPageProps {
onLogin: (user: User) => void
}
export default function LoginPage({ onLogin }: LoginPageProps) {
const t = useT()
const [usr, setUsr] = useState('')
const [pwd, setPwd] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [usrFocus, setUsrFocus] = useState(false)
const [pwdFocus, setPwdFocus] = useState(false)
const handleLogin = async () => {
if (!usr || !pwd) { setError(t.login.emptyFields); return }
setLoading(true)
setError('')
try {
const data = await loginApi(usr, pwd)
if (data.message === 'Logged In') {
const sessionUser = await getSessionUser()
const actualName = sessionUser || usr
const roles = await getUserRoles(actualName)
const role: string = roles.some(r => ['CEO', 'Ceo', 'SB CEO', 'Chairman'].includes(r))
? 'CEO'
: roles.some(r => ['Business Owner', 'SB Business Owner', 'SB Owner', 'Owner'].includes(r))
? 'Business Owner'
: roles.some(r => ['SB Admin', 'System Manager', 'Administrator'].includes(r))
? 'Admin'
: roles.includes('SB Auditor')
? 'Auditor'
: roles.includes('SB X-Auditor')
? 'XAuditor'
: roles.includes('SB Manager')
? 'Manager'
: 'Employee'
onLogin({ name: actualName, full_name: data.full_name || usr, role })
} else {
setError(t.login.invalidCreds)
}
} catch (e) {
setError(t.login.connError + (e instanceof Error ? e.message : 'Unknown'))
}
setLoading(false)
}
const inputStyle = (focused: boolean): React.CSSProperties => ({
width: '100%',
padding: '11px 16px',
border: `1.5px solid ${focused ? '#6366f1' : '#e2e8f0'}`,
borderRadius: 8,
fontSize: 14,
background: focused ? '#f8faff' : '#ffffff',
color: '#1e293b',
outline: 'none',
transition: 'all 0.18s ease',
boxSizing: 'border-box',
boxShadow: focused ? '0 0 0 3px rgba(99, 102, 241, 0.15)' : 'none',
})
return (
<div
style={{
minHeight: '100vh',
background: '#f8fafc',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'Segoe UI, system-ui, sans-serif',
padding: '20px',
}}
>
<div
style={{
width: 380,
maxWidth: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
{/* Modern Logo Card */}
<div
style={{
width: 72,
height: 72,
borderRadius: 18,
background: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 20,
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.04)',
border: '1px solid #f1f5f9',
overflow: 'hidden',
padding: 10,
}}
>
<img
src={logoImg}
alt="Logo"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
onError={e => {
(e.currentTarget as HTMLElement).style.display = 'none'
}}
/>
</div>
{/* Brand Titles */}
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<h1
style={{
fontSize: 22,
fontWeight: 800,
color: '#0f172a',
letterSpacing: '-0.3px',
margin: 0,
}}
>
{t.rtl ? 'صدرة للأعمال' : 'Sidrah'}
</h1>
<div
style={{
fontSize: 13,
color: '#6366f1',
fontWeight: 600,
marginTop: 4,
}}
>
{t.rtl ? 'نظام إدارة العمليات والأعمال' : 'Asset Management System'}
</div>
<div
style={{
fontSize: 12,
color: '#94a3b8',
marginTop: 6,
}}
>
{t.login.subtitle || 'Sign in to continue'}
</div>
</div>
{/* Input Form Card */}
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
>
<div>
<input
value={usr}
onChange={e => setUsr(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleLogin()}
onFocus={() => setUsrFocus(true)}
onBlur={() => setUsrFocus(false)}
style={inputStyle(usrFocus)}
placeholder={t.login.usernamePlaceholder || 'Username'}
autoComplete="username"
/>
</div>
<div>
<input
type="password"
value={pwd}
onChange={e => setPwd(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleLogin()}
onFocus={() => setPwdFocus(true)}
onBlur={() => setPwdFocus(false)}
style={inputStyle(pwdFocus)}
placeholder={t.login.passwordPlaceholder || 'Password'}
autoComplete="current-password"
/>
</div>
{error && (
<div
style={{
padding: '9px 12px',
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: 8,
color: '#dc2626',
fontSize: 12,
textAlign: 'center',
}}
>
{error}
</div>
)}
<button
type="button"
onClick={handleLogin}
disabled={loading}
style={{
background: loading ? '#818cf8' : '#4f46e5',
color: 'white',
border: 'none',
padding: '12px',
borderRadius: 8,
fontSize: 14,
cursor: loading ? 'not-allowed' : 'pointer',
fontWeight: 600,
marginTop: 4,
boxShadow: '0 2px 8px rgba(79, 70, 229, 0.3)',
transition: 'all 0.18s ease',
}}
onMouseEnter={e => {
if (!loading) (e.currentTarget as HTMLButtonElement).style.background = '#4338ca'
}}
onMouseLeave={e => {
if (!loading) (e.currentTarget as HTMLButtonElement).style.background = '#4f46e5'
}}
>
{loading ? (t.login.signingIn || 'Signing in…') : (t.login.signIn || 'Login')}
</button>
{/* Language toggle */}
<div style={{ textAlign: 'center', marginTop: 8 }}>
<button
type="button"
onClick={toggleLang}
style={{
fontSize: 12,
color: '#94a3b8',
background: 'none',
border: 'none',
cursor: 'pointer',
}}
onMouseEnter={e => (e.currentTarget.style.color = '#475569')}
onMouseLeave={e => (e.currentTarget.style.color = '#94a3b8')}
>
{t.switchLang}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,102 @@
import { useT } from '../i18n'
import { Dispatch, SetStateAction } from 'react'
import { Management, Organization, User, Page, SBGroupCompany } from '../types'
import { inp, lbl, FormCard, DeletedBanner } from '../components/common'
import FormPageLayout from '../components/FormPageLayout'
import OrgSelect from '../components/OrgSelect'
import DateInput from '../components/DateInput'
interface ManagementFormPageProps {
form: Management
setForm: Dispatch<SetStateAction<Management>>
organizations: Organization[]
groupCompanies?: SBGroupCompany[]
formStatus: string
viewMode?: boolean
onEdit?: () => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
export default function ManagementFormPage({
form, setForm, organizations, groupCompanies: _gc = [], formStatus, viewMode, onEdit, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile,
onSubmit, onBack, onNavigate, onLogout, user
}: ManagementFormPageProps) {
const t = useT()
const set = (patch: Partial<Management>) => setForm(f => ({ ...f, ...patch }))
const di: React.CSSProperties = viewMode ? { background: '#f8fafc' } : {}
const title = form.name
? (viewMode ? `${t.formTitle.editMgmt}: ${form.name1 || form.name}` : t.formTitle.editMgmt)
: t.formTitle.newMgmt
return (
<FormPageLayout
title={title} activePage="managements"
user={user} formStatus={formStatus}
buttonText={t.saveMgmt} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{(form as any).custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={(form as any).custom_deleted_by}
deletedOn={(form as any).custom_deleted_on}
reason={(form as any).custom_deleted_reason}
onRestore={onRestore && form.name ? () => onRestore(form.name) : undefined}
onPermanentDelete={onPermanentDelete && form.name ? () => onPermanentDelete(form.name) : undefined}
/>
)}
<FormCard title="Management Details">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
<div>
<label style={lbl}>{t.field.organization} <span style={{ color: '#e74c3c' }}>*</span></label>
<OrgSelect
value={form.organization}
onChange={v => {
const orgCo = organizations.find(o => o.name === v)?.group_company
set({ organization: v, ...(user.custom_group_company ? {} : { group_company: orgCo || form.group_company }) })
}}
organizations={organizations}
disabled={viewMode || !!user.custom_organization}
userRole={user.role}
/>
</div>
<div>
<label style={lbl}>{t.field.name1} <span style={{ color: '#e74c3c' }}>*</span></label>
<input
style={{ ...inp, ...di }}
value={form.name1}
disabled={viewMode || !!form.name}
onChange={e => set({ name1: e.target.value })}
placeholder="Management name"
required
/>
</div>
<div>
<label style={lbl}>{t.field.creationDate}</label>
<DateInput value={form.creation_date || ''} onChange={v => set({ creation_date: v })} disabled={viewMode} style={{ ...inp, ...di }} />
</div>
<div>
<label style={lbl}>{t.field.status}</label>
<select style={{ ...inp, ...di }} value={form.status || 'Active'} disabled={viewMode}
onChange={e => set({ status: e.target.value })}>
<option value="Active">{t.field.statusActive}</option>
<option value="Inactive">{t.field.statusInactive}</option>
</select>
</div>
</div>
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from 'react'
import { Management } from '../types'
import { RowActions } from '../components/common'
import StatCard from '../components/StatCard'
import FilterBar from '../components/FilterBar'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { colors } from '../constants'
import { useT } from '../i18n'
interface ManagementPageProps {
managements: Management[]
canModify?: boolean
onView?: (m: Management) => void
onEdit?: (m: Management) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: Management[], page: Management[]) => void
}
function fmtDate(d?: string) {
if (!d) return '—'
const dt = new Date(d)
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')}/${dt.getFullYear()}`
}
export default function ManagementPage({ managements: allManagements, canModify, onView, onEdit, onDelete, onSelectionChange }: ManagementPageProps) {
const t = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }
const managements = useMemo(() => allManagements.filter(m => {
const label = (m.name1 || m.name || '').toLowerCase()
if (search && !label.includes(search.toLowerCase())) return false
if (statusFilter !== 'all' && (m.status || 'Active') !== statusFilter) return false
return true
}), [allManagements, search, statusFilter])
const [page, setPage] = useState(1)
useEffect(() => { setPage(1) }, [search, statusFilter])
const pageRows = managements.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = allManagements.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows])
return (
<>
<div style={{ display: 'flex', gap: 16 }}>
<StatCard label="Total" val={managements.length} color={colors.blue} />
</div>
<FilterBar filters={[
{ type: 'search', label: 'Search', value: search, onChange: setSearch, placeholder: 'Search by name…' },
{ type: 'select', label: 'Status', value: statusFilter, onChange: setStatusFilter, options: [
{ value: 'all', label: 'All' }, { value: 'Active', label: t.field.statusActive }, { value: 'Inactive', label: t.field.statusInactive },
] },
]} />
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8f9fa', position: 'sticky', top: 0 }}>
<th style={{ width: 40, padding: '10px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={th}>{t.col.name}</th>
<th style={th}>{t.field.creationDate}</th>
<th style={th}>{t.field.status}</th>
<th style={th}>{t.col.actions}</th>
</tr>
</thead>
<tbody>
{managements.length === 0
? <tr><td colSpan={5} style={{ padding: 40, textAlign: 'center', color: '#aaa' }}>{t.noData}</td></tr>
: pageRows.map((m, i) => (
<tr
key={m.name}
className="app-table-row"
onClick={() => onView?.(m)}
style={{
borderTop: '1px solid #f0f0f0',
background: selectedIds.has(m.name) ? '#eff6ff' : ((m as any).custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer'
}}
>
<td style={{ width: 40, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(m.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(m.name) ? next.delete(m.name) : next.add(m.name)
setSelectedIds(next)
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{m.name1 || m.name || '—'}</td>
<td style={{ padding: '10px 14px', color: '#666' }}>{fmtDate(m.creation_date)}</td>
<td style={{ padding: '10px 14px' }}>
{(m as any).custom_is_deleted ? (
<span style={{ background: '#fee2e2', color: '#991b1b', padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 700 }}>
{t.deletedBadge || 'DELETED'}
</span>
) : (
<span style={{ color: m.status === 'Inactive' ? '#e74c3c' : '#16a34a', fontWeight: 600 }}>
{m.status === 'Inactive' ? t.field.statusInactive : t.field.statusActive}
</span>
)}
</td>
<td style={{ padding: '8px 14px' }} onClick={e => e.stopPropagation()}>
<RowActions onView={() => onView?.(m)} onEdit={canModify && onEdit && !(m as any).custom_is_deleted ? () => onEdit(m) : undefined} onDelete={canModify && onDelete && !(m as any).custom_is_deleted ? () => onDelete(m.name) : undefined} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={managements.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,715 @@
import { useState, useEffect } from 'react'
import { useT } from '../i18n'
import { colors, DOCTYPES } from '../constants'
import { SBCycle, Organization, Management, Department, Branch, OrderType, OrderStatus, User, Page, SBGroupCompany } from '../types'
import { inp, lbl, usersWithRole, activeItems, filterOrgItems, DeletedBanner, DeleteRecordCard, FormCard, StatusSummaryCard } from '../components/common'
import DateInput from '../components/DateInput'
import FormPageLayout from '../components/FormPageLayout'
import OrgSelect from '../components/OrgSelect'
import LinkSelect from '../components/LinkSelect'
import CommentSection from '../components/CommentSection'
import ActivityLog from '../components/ActivityLog'
import { applyOrderWorkflow } from '../api'
interface OrderForm {
name: string
number: string; title: string; organization: string; group_company?: string; type: string
management: string; department: string; section_field: string; branch: string
starting_date: string; ending_date: string; assigned_to: string; actor: string
status: string; budget: number; actual_cost: number; currency: string; description: string; notes: string
cycle: string; stage: number; completion_pct: number; escalation_level: number
workflow_state: string
// Classification
planned: number; priority: string; product_relation: number
entity_scope: string; economy_class: string; hses_field: string
// Comment
auditor_comment: string
sb_idea: string
cycle_type: string
long_cycle_date_1: string
long_cycle_date_2: string
long_cycle_date_3: string
long_cycle_date_4: string
custom_is_deleted?: number
custom_deleted_by?: string
custom_deleted_on?: string
custom_deleted_reason?: string
}
interface OrderFormPageProps {
orderForm: OrderForm
setOrderForm: React.Dispatch<React.SetStateAction<any>> | ((f: any) => void)
organizations: Organization[]
groupCompanies?: SBGroupCompany[]
managements: Management[]
departments: Department[]
branches: Branch[]
orderTypes: OrderType[]
orderStatuses: OrderStatus[]
cycles?: SBCycle[]
users: any[]
userRoleMap?: Record<string, string[]>
formStatus: string
viewMode?: boolean
canModify?: boolean
onEdit?: () => void
onDelete?: (name: string) => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
onNavigateToIdea?: (ideaName: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
currentUser?: string
userRole?: string
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
onRefresh?: () => void
}
const STAGE_DESCRIPTIONS: Record<number, string> = {
1: 'Initiation / Bidding',
2: 'Execution begins',
3: 'Mid-execution review',
4: 'Near completion',
5: 'Closing',
}
const ORDER_STATE_BADGE: Record<string, React.CSSProperties> = {
Draft: { background: '#eff6ff', color: '#1e40af', border: '1px solid #bfdbfe' },
'Pending Actor': { background: '#fff7ed', color: '#c2410c', border: '1px solid #fed7aa' },
Review: { background: '#fefce8', color: '#92400e', border: '1px solid #fde68a' },
'In Study': { background: '#f5f3ff', color: '#6d28d9', border: '1px solid #ddd6fe' },
Closed: { background: '#f0fdf4', color: '#166534', border: '1px solid #bbf7d0' },
}
function orderStateBadge(state: string): React.CSSProperties {
return {
display: 'inline-block', padding: '4px 12px', borderRadius: 20,
fontSize: 13, fontWeight: 700,
...(ORDER_STATE_BADGE[state] ?? { background: '#f3f4f6', color: '#374151', border: '1px solid #e5e7eb' }),
}
}
export default function OrderFormPage({
orderForm, setOrderForm, organizations, groupCompanies: _gc = [], managements, departments, branches,
orderTypes, orderStatuses: _orderStatuses, cycles = [], users = [], userRoleMap = {},
formStatus, viewMode, canModify, onEdit, onDelete, onRestore, onPermanentDelete, onNavigateToIdea, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile,
currentUser, userRole, onSubmit, onBack, onNavigate, onLogout, user, onRefresh
}: OrderFormPageProps) {
const tl = useT()
const set = (patch: Partial<OrderForm>) => setOrderForm({ ...orderForm, ...patch })
const di: React.CSSProperties = viewMode ? { background: '#f9f9f9' } : {}
const employeeUsers = usersWithRole(users, userRoleMap, 'SB Employee', orderForm.organization)
const [wfStatus, setWfStatus] = useState('')
const [actionComment, setActionComment] = useState('')
const [wfLoading, setWfLoading] = useState(false)
const [cycleStatus, setCycleStatus] = useState<'Agree' | 'Deviate' | 'Unknown'>('Agree')
const title = orderForm.name
? (viewMode ? `${tl.formTitle.viewOrder}${orderForm.number || orderForm.title || orderForm.name}` : tl.formTitle.editOrder)
: tl.formTitle.newOrder
const filteredMgmts = filterOrgItems(managements, { organization: orderForm.organization })
const filteredDepts = filterOrgItems(departments, { organization: orderForm.organization, management: orderForm.management })
const filteredBranches = filterOrgItems(branches, { organization: orderForm.organization, management: orderForm.management, department: orderForm.department })
const mgmtItems = activeItems(filteredMgmts, orderForm.management).map(m => ({ name: m.name, label: m.name1 || m.name }))
const deptItems = activeItems(filteredDepts, orderForm.department).map(d => ({ name: d.name, label: d.department_name || d.name }))
const branchItems = activeItems(filteredBranches, orderForm.branch).map(b => ({ name: b.name, label: b.branch || b.name }))
const typeItems = activeItems(orderTypes, orderForm.type).map(ot => ({ name: ot.name, label: ot.name1 || ot.name }))
const currencyList = ['SAR', 'USD', 'EUR', 'AED', 'GBP', 'KWD', 'BHD', 'QAR', 'OMR', 'EGP']
const currencyItems = Array.from(new Set([...currencyList, orderForm.currency].filter(Boolean))).map(c => ({ name: c, label: c }))
const cycleItems = activeItems(cycles, orderForm.cycle).map(c => ({ name: c.name, label: (c as any).name1 || (c as any).cycle_name || c.name }))
const isExisting = !!orderForm.name
const wfState = orderForm.workflow_state || 'Draft'
const nextStage = Math.min(5, (orderForm.stage || 0) + 1)
const [updateStage, setUpdateStage] = useState(nextStage)
useEffect(() => { setUpdateStage(Math.min(5, (orderForm.stage || 0) + 1)) }, [orderForm.stage])
const isAuditorAdmin = userRole === 'Auditor' || userRole === 'Admin' || userRole === 'Manager'
const isOwnerOrAbove = isAuditorAdmin || userRole === 'Owner'
// Actor is identified via the 'actor' field (new) or the legacy 'assigned_to' field
const isActorUser = !!(currentUser && (
orderForm.actor === currentUser || orderForm.assigned_to === currentUser
))
type OrderAction = { label: string; action: string; color: string }
const isCompletedOrder = orderForm.stage === 5 || orderForm.completion_pct === 100 || orderForm.status === 'Completion' || wfState === 'Completed' || wfState === 'Closed'
const cycleLogs = (orderForm as any).order_cycle_log || []
const lastCycleStatus = cycleLogs.length > 0 ? cycleLogs[cycleLogs.length - 1].status : cycleStatus
const isDeviatedOrUnknown = lastCycleStatus === 'Deviate' || lastCycleStatus === 'Deviated' || lastCycleStatus === 'Unknown'
const availableActions: OrderAction[] = (() => {
if (!isExisting) return []
if (wfState === 'Closed' || wfState === 'Completed') return []
if (wfState === 'Draft' && isAuditorAdmin)
return [{ label: 'Submit for Review', action: 'Submit for Review', color: '#2563eb' }]
if (wfState === 'Pending Actor' && (isActorUser || isAuditorAdmin))
return [{ label: 'Update Status', action: 'Update Status', color: '#d97706' }]
if (wfState === 'Review' && isOwnerOrAbove) {
const actions: OrderAction[] = [
{ label: 'Move to Study', action: 'Move to Study', color: '#059669' }
]
if (!isCompletedOrder || isDeviatedOrUnknown) {
actions.push({ label: 'Start New Cycle', action: 'Start New Cycle', color: '#2563eb' })
}
if (isCompletedOrder) {
actions.push({ label: 'Close Order', action: 'Close Order', color: '#475569' })
}
return actions
}
if (wfState === 'In Study' && isOwnerOrAbove) {
const actions: OrderAction[] = []
if (!isCompletedOrder || isDeviatedOrUnknown) {
actions.push({ label: 'Start New Cycle', action: 'Start New Cycle', color: '#2563eb' })
}
actions.push({ label: 'Close Order', action: 'Close Order', color: '#475569' })
return actions
}
return []
})()
async function doWorkflowAction(action: string) {
if (!orderForm.name) return
setWfLoading(true); setWfStatus('')
try {
let cs: string | undefined
if (action === 'Update Status') cs = cycleStatus
const updated = await applyOrderWorkflow(orderForm.name, action, actionComment, action === 'Update Status' ? String(updateStage) : undefined, undefined, cs)
setActionComment('')
setCycleStatus('Agree')
setWfStatus('Done')
// Apply return value immediately so stage/completion/workflow_state update without extra round-trip
if (updated) {
const u = updated as any
setOrderForm({
...orderForm,
...u,
stage: u.stage ?? orderForm.stage,
completion_pct: u.completion_pct ?? orderForm.completion_pct,
workflow_state: u.workflow_state ?? orderForm.workflow_state,
})
}
onRefresh?.()
} catch (e: any) {
setWfStatus(e.message || 'Error')
} finally {
setWfLoading(false)
}
}
const assignedActorName = employeeUsers.find(u => u.name === (orderForm.actor || orderForm.assigned_to))?.full_name || orderForm.actor || orderForm.assigned_to
const rightPanel = isExisting ? (
<div style={{ width: 240, display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Workflow Actions Card */}
<div style={{ background: 'white', borderRadius: 10, padding: 14, border: '1px solid #e2e8f0', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#1e293b', marginBottom: 10 }}>
{tl.wf.workflow}
</div>
{/* Current State Subbox */}
<div style={{
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 8,
padding: '10px 12px',
marginBottom: 12
}}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.4px', marginBottom: 4 }}>
Current State
</div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#1e293b' }}>
{wfState}
</div>
</div>
{wfState === 'Pending Actor' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 12 }}>
<div>
<label style={lbl}>Stage (1–5) — {updateStage * 20}% complete</label>
<input type="number" min={1} max={5} style={{ ...inp, width: '100%' }}
value={updateStage}
onChange={e => setUpdateStage(Math.min(5, Math.max(1, parseInt(e.target.value) || 1)))} />
</div>
<div>
<label style={lbl}>Cycle Status</label>
<select style={inp} value={cycleStatus}
onChange={e => setCycleStatus(e.target.value as 'Agree' | 'Deviate' | 'Unknown')}>
<option value="Agree">✅ Agree — on track</option>
<option value="Deviate">⚠️ Deviate — off plan</option>
<option value="Unknown">❓ Unknown</option>
</select>
</div>
</div>
)}
<div style={{ marginBottom: 12 }}>
<label style={lbl}>
Comment {cycleStatus === 'Deviate' && wfState === 'Pending Actor'
? <span style={{ color: colors.red }}> * required for Deviate</span>
: '(optional)'}
</label>
<textarea style={{ ...inp, height: 60, resize: 'vertical', borderColor: cycleStatus === 'Deviate' && !actionComment ? '#fca5a5' : undefined }}
value={actionComment}
onChange={e => setActionComment(e.target.value)}
placeholder="Add a comment…" />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{availableActions.map(act => {
const deviateBlocked = act.action === 'Update Status' && cycleStatus === 'Deviate' && !actionComment.trim()
return (
<button key={act.action} onClick={() => doWorkflowAction(act.action)}
disabled={wfLoading || deviateBlocked}
title={deviateBlocked ? 'Comment required when status is Deviate' : undefined}
style={{
width: '100%',
padding: '8px 12px',
borderRadius: 6,
border: 'none',
background: act.color,
color: 'white',
fontWeight: 600,
fontSize: 12,
cursor: (wfLoading || deviateBlocked) ? 'not-allowed' : 'pointer',
opacity: (wfLoading || deviateBlocked) ? 0.5 : 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6
}}>
<span>{wfLoading ? tl.wf.applying : act.label}</span>
</button>
)
})}
</div>
{wfStatus && <p style={{ marginTop: 8, fontSize: 12, color: wfStatus === 'Done' ? colors.green : colors.red }}>{wfStatus}</p>}
{availableActions.length === 0 && (
<div style={{ fontSize: 11, color: '#94a3b8', fontStyle: 'italic' }}>{tl.wf.noActions}</div>
)}
</div>
{/* Status Summary Card */}
<StatusSummaryCard
priority={orderForm.priority}
status={wfState}
assignedTo={userRole !== 'Employee' ? assignedActorName : undefined}
createdDate={orderForm.starting_date}
modifiedDate={(orderForm as any).modified}
/>
<CommentSection doctype={DOCTYPES.orders} docname={orderForm.name} currentUserName={user.name} />
<ActivityLog doctype={DOCTYPES.orders} docname={orderForm.name} docCreation={(orderForm as any).creation} docOwner={(orderForm as any).owner} />
{canModify && onDelete && !orderForm.custom_is_deleted && (
<DeleteRecordCard
title="Delete Request"
btnText={tl.delete || 'Delete'}
onDelete={() => onDelete(orderForm.name)}
/>
)}
</div>
) : null
return (
<FormPageLayout
title={title}
activePage="orders"
user={user} formStatus={formStatus}
statusBadge={<span style={orderStateBadge(wfState)}>{wfState}</span>}
buttonText={tl.saveOrder} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
rightPanel={rightPanel}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{orderForm.custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={orderForm.custom_deleted_by}
deletedOn={orderForm.custom_deleted_on}
reason={orderForm.custom_deleted_reason}
onRestore={onRestore ? () => onRestore(orderForm.name) : undefined}
onPermanentDelete={onPermanentDelete ? () => onPermanentDelete(orderForm.name) : undefined}
/>
)}
{orderForm.sb_idea && (
<div style={{
background: '#f0f7ff',
border: '1px solid #bfdbfe',
borderRadius: 8,
padding: '8px 14px',
marginBottom: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
fontSize: 12,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, overflow: 'hidden' }}>
<span style={{ fontSize: 14, flexShrink: 0 }}>💡</span>
<span style={{ fontSize: 11, fontWeight: 700, color: '#1e40af', textTransform: 'uppercase', letterSpacing: '0.4px', whiteSpace: 'nowrap' }}>
{tl.field.originatingIdea}:
</span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#1e3a8a', whiteSpace: 'nowrap' }}>
{orderForm.sb_idea}
</span>
</div>
{onNavigateToIdea && (
<button
type="button"
onClick={() => onNavigateToIdea(orderForm.sb_idea!)}
style={{
background: '#2563eb',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '4px 10px',
fontWeight: 600,
fontSize: 11,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
flexShrink: 0,
transition: 'background 0.15s ease',
}}
onMouseEnter={e => (e.currentTarget.style.background = '#1d4ed8')}
onMouseLeave={e => (e.currentTarget.style.background = '#2563eb')}
>
{tl.field.viewIdea} →
</button>
)}
</div>
)}
{/* Section 1: Order Information */}
<FormCard title="Order Information">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
{orderForm.name && (
<div>
<label style={lbl}>{tl.field.number}</label>
<input style={{ ...inp, background: '#f8fafc', color: '#475569', fontWeight: 600 }} value={orderForm.number} readOnly />
</div>
)}
<div>
<label style={lbl}>{tl.field.organization} <span style={{ color: '#e74c3c' }}>*</span></label>
<OrgSelect
value={orderForm.organization}
onChange={v => {
const orgCo = organizations.find(o => o.name === v)?.group_company
set({ organization: v, ...(user.custom_group_company ? {} : { group_company: orgCo || orderForm.group_company }) })
}}
organizations={organizations}
disabled={viewMode || !!user.custom_organization}
userRole={userRole}
onQuickCreateSuccess={onRefresh}
/>
</div>
<div>
<label style={lbl}>{tl.field.title}</label>
<input style={{ ...inp, ...di }} value={orderForm.title} disabled={viewMode} onChange={e => set({ title: e.target.value })} />
</div>
<div>
<label style={lbl}>{tl.field.management}</label>
<LinkSelect
value={orderForm.management}
onChange={v => set({ management: v })}
items={mgmtItems}
placeholder={tl.field.select}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.managements}
extraFields={{ organization: orderForm.organization }}
onQuickCreateSuccess={onRefresh}
/>
</div>
<div>
<label style={lbl}>{tl.field.department}</label>
<LinkSelect
value={orderForm.department}
onChange={v => set({ department: v })}
items={deptItems}
placeholder={tl.field.select}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.departments}
extraFields={{ organization: orderForm.organization, management: orderForm.management }}
onQuickCreateSuccess={onRefresh}
/>
</div>
<div>
<label style={lbl}>{tl.field.section}</label>
<input style={{ ...inp, ...di }} value={orderForm.section_field || ''} disabled={viewMode} onChange={e => set({ section_field: e.target.value })} />
</div>
<div>
<label style={lbl}>{tl.field.branch}</label>
<LinkSelect
value={orderForm.branch}
onChange={v => set({ branch: v })}
items={branchItems}
placeholder={tl.field.select}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.branches}
extraFields={{ organization: orderForm.organization, management: orderForm.management, department: orderForm.department }}
onQuickCreateSuccess={onRefresh}
/>
</div>
<div>
<label style={lbl}>{tl.field.type}</label>
<LinkSelect
value={orderForm.type}
onChange={v => set({ type: v })}
items={typeItems}
placeholder={tl.field.selectType}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.orderTypes}
onQuickCreateSuccess={onRefresh}
/>
</div>
</div>
</FormCard>
{/* Section 2: Financial & Schedule */}
<FormCard title="Financial & Schedule">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 14 }}>
<div>
<label style={lbl}>{tl.field.budget}</label>
<input type="number" style={{ ...inp, ...di }} value={orderForm.budget || ''} disabled={viewMode} onChange={e => set({ budget: parseFloat(e.target.value) || 0 })} />
</div>
<div>
<label style={lbl}>{tl.field.actualCost}</label>
<input type="number" style={{ ...inp, ...di }} value={orderForm.actual_cost || ''} disabled={viewMode} onChange={e => set({ actual_cost: parseFloat(e.target.value) || 0 })} />
</div>
<div>
<label style={lbl}>Variance</label>
{(() => {
const v = (orderForm.budget || 0) - (orderForm.actual_cost || 0)
return (
<div style={{ padding: '7px 12px', borderRadius: 6, fontWeight: 700, fontSize: 13, background: v >= 0 ? '#f0fdf4' : '#fef2f2', color: v >= 0 ? '#166534' : '#dc2626', border: `1px solid ${v >= 0 ? '#bbf7d0' : '#fecaca'}`, display: 'inline-block', width: '100%', boxSizing: 'border-box' }}>
{v >= 0 ? '+' : ''}{v.toFixed(2)}
</div>
)
})()}
</div>
<div>
<label style={lbl}>{tl.field.currency}</label>
<LinkSelect
value={orderForm.currency || ''}
onChange={v => set({ currency: v })}
items={currencyItems}
placeholder={tl.field.select}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.currency}
onQuickCreateSuccess={onRefresh}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16 }}>
<div>
<label style={lbl}>{tl.field.startingDate}</label>
<DateInput value={orderForm.starting_date} onChange={v => set({ starting_date: v })} disabled={viewMode} style={{ ...inp, ...di }} />
</div>
<div>
<label style={lbl}>{tl.field.endingDate}</label>
<DateInput value={orderForm.ending_date} onChange={v => set({ ending_date: v })} disabled={viewMode} style={{ ...inp, ...di }} />
</div>
{userRole !== 'Employee' && (
<div>
<label style={lbl}>{tl.field.actor}</label>
{viewMode ? (
<input
style={{ ...inp, background: '#f8fafc', fontWeight: 600, color: '#334155' }}
value={assignedActorName || '—'}
readOnly
disabled
/>
) : (
<select
style={{ ...inp, background: '#ffffff' }}
value={orderForm.actor || orderForm.assigned_to || ''}
onChange={e => set({ actor: e.target.value, assigned_to: e.target.value })}
>
<option value="">{tl.field.select}</option>
{employeeUsers.map(u => (
<option key={u.name} value={u.name}>{u.full_name || u.name}</option>
))}
</select>
)}
</div>
)}
</div>
</FormCard>
{/* Section 3: Classification */}
<FormCard title="Classification">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 12 }}>
<div>
<label style={lbl}>{tl.field.priority}</label>
<select style={{ ...inp, ...di }} value={orderForm.priority || ''} disabled={viewMode} onChange={e => set({ priority: e.target.value })}>
<option value="">{tl.field.select}</option>
<option value="High">{tl.field.high}</option>
<option value="Normal">{tl.field.normal}</option>
</select>
</div>
<div>
<label style={lbl}>{tl.field.economyClass}</label>
<select style={{ ...inp, ...di }} value={orderForm.economy_class || ''} disabled={viewMode} onChange={e => set({ economy_class: e.target.value })}>
<option value="">{tl.field.select}</option>
<option value="Expenses">{tl.field.expenses}</option>
<option value="Capital">{tl.field.capital}</option>
<option value="Earn">{tl.field.earn}</option>
<option value="Pay/Loss">{tl.field.payLoss}</option>
</select>
</div>
<div>
<label style={lbl}>{tl.field.entityScope}</label>
<select style={{ ...inp, ...di }} value={orderForm.entity_scope || ''} disabled={viewMode} onChange={e => set({ entity_scope: e.target.value })}>
<option value="">{tl.field.select}</option>
<option value="Upstream">{tl.field.upstream}</option>
<option value="On-stream">{tl.field.onstream}</option>
<option value="Downstream">{tl.field.downstream}</option>
</select>
</div>
</div>
<div style={{ display: 'flex', gap: 24, marginTop: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
<input type="checkbox" checked={!!orderForm.planned} disabled={viewMode}
onChange={e => set({ planned: e.target.checked ? 1 : 0 })} /> Planned
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
<input type="checkbox" checked={!!orderForm.product_relation} disabled={viewMode}
onChange={e => set({ product_relation: e.target.checked ? 1 : 0 })} /> {tl.field.productRelation}
</label>
</div>
<div style={{ marginTop: 12 }}>
<label style={lbl}>{tl.field.hsesField}</label>
<input style={{ ...inp, ...di }} value={orderForm.hses_field || ''} disabled={viewMode} onChange={e => set({ hses_field: e.target.value })} placeholder="Safety, Health, Environment…" />
</div>
</FormCard>
{/* Section 4: Cycle & Progress */}
<FormCard title="Cycle & Progress Tracking">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 14 }}>
<div>
<label style={lbl}>{tl.field.cycle}</label>
<LinkSelect
value={orderForm.cycle || ''}
onChange={v => set({ cycle: v })}
items={cycleItems}
placeholder={tl.field.select}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.cycles}
extraFields={{ organization: orderForm.organization }}
onQuickCreateSuccess={onRefresh}
/>
</div>
<div>
<label style={lbl}>{tl.field.cycleType || 'Cycle Type'}</label>
<select style={{ ...inp, ...di }} value={orderForm.cycle_type || 'Normal'} disabled={viewMode} onChange={e => set({ cycle_type: e.target.value })}>
<option value="Normal">Normal</option>
<option value="Long">Long</option>
</select>
</div>
<div>
<label style={lbl}>{tl.field.stage}</label>
<input type="number" min={1} max={5} style={{ ...inp, background: '#f8fafc', color: '#334155', fontWeight: 600 }} value={orderForm.stage || 1} readOnly />
</div>
<div>
<label style={lbl}>Completion</label>
<input style={{ ...inp, background: '#f8fafc', color: '#334155', fontWeight: 600 }} value={`${orderForm.completion_pct || (orderForm.stage || 1) * 20}%`} readOnly />
</div>
<div>
<label style={lbl}>Stage Description</label>
<span style={{ display: 'inline-block', padding: '7px 12px', borderRadius: 6, fontSize: 13, fontWeight: 600, background: '#f0f9ff', color: '#0369a1', border: '1px solid #bae6fd', width: '100%', boxSizing: 'border-box' }}>
{STAGE_DESCRIPTIONS[orderForm.stage || 1] || '—'}
</span>
</div>
</div>
{/* Long cycle milestone dates shown when cycle_type = Long (matching Process logic) */}
{orderForm.cycle_type === 'Long' && (
<div style={{ background: '#fff7ed', border: '1px solid #fed7aa', borderRadius: 8, padding: 14, marginBottom: 14 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: '#c2410c', marginBottom: 10 }}>Long Cycle Milestone Dates</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
<div><label style={lbl}>Milestone 1</label><DateInput value={orderForm.long_cycle_date_1 || ''} onChange={v => set({ long_cycle_date_1: v })} disabled={viewMode} style={{ ...inp, ...di }} /></div>
<div><label style={lbl}>Milestone 2</label><DateInput value={orderForm.long_cycle_date_2 || ''} onChange={v => set({ long_cycle_date_2: v })} disabled={viewMode} style={{ ...inp, ...di }} /></div>
<div><label style={lbl}>Milestone 3</label><DateInput value={orderForm.long_cycle_date_3 || ''} onChange={v => set({ long_cycle_date_3: v })} disabled={viewMode} style={{ ...inp, ...di }} /></div>
<div><label style={lbl}>Milestone 4</label><DateInput value={orderForm.long_cycle_date_4 || ''} onChange={v => set({ long_cycle_date_4: v })} disabled={viewMode} style={{ ...inp, ...di }} /></div>
</div>
</div>
)}
{/* Cycle History log */}
{isExisting && (orderForm as any).order_cycle_log?.length > 0 && (
<div style={{ borderTop: '1px solid #f1f5f9', paddingTop: 14, marginTop: 10 }}>
<p style={{ fontWeight: 700, color: '#334155', fontSize: 13, marginBottom: 10 }}>Cycle History</p>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8fafc' }}>
{['Cycle', 'Date', 'Stage', 'Status', 'Actor', 'Comment'].map(h => (
<th key={h} style={{ padding: '6px 10px', textAlign: 'left', fontWeight: 600, color: '#475569', borderBottom: '1px solid #e2e8f0' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{((orderForm as any).order_cycle_log || []).map((row: any, i: number) => {
const statusColor = row.status === 'Agree' ? '#166534' : row.status === 'Deviate' ? '#c2410c' : '#92400e'
const statusBg = row.status === 'Agree' ? '#f0fdf4' : row.status === 'Deviate' ? '#fff7ed' : '#fefce8'
return (
<tr key={i} style={{ borderBottom: '1px solid #f1f5f9' }}>
<td style={{ padding: '6px 10px', color: '#334155' }}>{row.cycle || '—'}</td>
<td style={{ padding: '6px 10px', color: '#334155' }}>{row.cycle_date || '—'}</td>
<td style={{ padding: '6px 10px', color: '#334155', textAlign: 'center' }}>{row.stage || '—'}</td>
<td style={{ padding: '6px 10px' }}>
<span style={{ background: statusBg, color: statusColor, padding: '2px 8px', borderRadius: 12, fontWeight: 600, fontSize: 12 }}>{row.status || '—'}</span>
</td>
<td style={{ padding: '6px 10px', color: '#334155' }}>{row.actor || '—'}</td>
<td style={{ padding: '6px 10px', color: '#64748b', fontStyle: row.comment ? 'normal' : 'italic' }}>{row.comment || '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
</FormCard>
{/* Section 5: Description & Notes */}
<FormCard title="Description & Notes">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16, marginBottom: 14 }}>
<div>
<label style={lbl}>{tl.field.description}</label>
<textarea style={{ ...inp, ...di, height: 90, resize: 'vertical' }} value={orderForm.description} disabled={viewMode} onChange={e => set({ description: e.target.value })} />
</div>
<div>
<label style={lbl}>{tl.field.notes}</label>
<textarea style={{ ...inp, ...di, height: 90, resize: 'vertical' }} value={orderForm.notes} disabled={viewMode} onChange={e => set({ notes: e.target.value })} />
</div>
</div>
<div>
<label style={lbl}>{tl.field.auditorComment}</label>
<textarea style={{ ...inp, height: 70, resize: 'vertical', ...(viewMode && isActorUser && !isAuditorAdmin ? { background: '#f8fafc' } : {}) }}
value={orderForm.auditor_comment || ''} disabled={viewMode && isActorUser && !isAuditorAdmin}
onChange={e => set({ auditor_comment: e.target.value })} placeholder={tl.field.commentPlaceholder} />
</div>
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,108 @@
import { useT } from '../i18n'
import { Dispatch, SetStateAction } from 'react'
import { OrderStatus, User, Page } from '../types'
import { inp, lbl, chk, FormCard, DeletedBanner } from '../components/common'
import DateInput from '../components/DateInput'
import FormPageLayout from '../components/FormPageLayout'
interface Props {
form: OrderStatus
setForm: Dispatch<SetStateAction<OrderStatus>>
formStatus: string
viewMode?: boolean
onEdit?: () => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
export default function OrderStatusFormPage({ form, setForm, formStatus, viewMode, onEdit, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile, onSubmit, onBack, onNavigate, onLogout, user }: Props) {
const t = useT()
const set = (patch: Partial<OrderStatus>) => setForm(f => ({ ...f, ...patch }))
const di: React.CSSProperties = viewMode ? { background: '#f8fafc' } : {}
const title = form.name
? (viewMode ? `Order Status: ${form.name1 || form.name}` : 'Edit Order Status')
: 'New Order Status'
return (
<FormPageLayout
title={title} activePage="order-statuses"
user={user} formStatus={formStatus}
buttonText={t.saveName} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{(form as any).custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={(form as any).custom_deleted_by}
deletedOn={(form as any).custom_deleted_on}
reason={(form as any).custom_deleted_reason}
onRestore={onRestore && form.name ? () => onRestore(form.name) : undefined}
onPermanentDelete={onPermanentDelete && form.name ? () => onPermanentDelete(form.name) : undefined}
/>
)}
<FormCard title="Order Status Details">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16, marginBottom: 14 }}>
<div>
<label style={lbl}>{t.field.name1} <span style={{ color: '#e74c3c' }}>*</span></label>
<input
style={{ ...inp, ...di }}
value={form.name1}
disabled={viewMode || !!form.name}
onChange={e => set({ name1: e.target.value })}
placeholder="Order status name"
required
/>
</div>
<div>
<label style={lbl}>{t.field.creationDate}</label>
<DateInput
value={form.creation_date || ''}
onChange={v => set({ creation_date: v })}
disabled={viewMode}
style={{ ...inp, ...di }}
/>
</div>
<div>
<label style={lbl}>{t.field.status}</label>
<select
style={{ ...inp, ...di }}
value={form.status || 'Active'}
disabled={viewMode}
onChange={e => set({ status: e.target.value })}
>
<option value="Active">{t.field.statusActive}</option>
<option value="Inactive">{t.field.statusInactive}</option>
</select>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
id="notify_manager"
checked={!!form.notify_manager}
disabled={viewMode}
onChange={e => set({ notify_manager: e.target.checked ? 1 : 0 })}
style={chk}
/>
<label htmlFor="notify_manager" style={{ fontSize: 13, cursor: viewMode ? 'default' : 'pointer' }}>
{t.field.notifyManager}
</label>
</div>
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,141 @@
import { useEffect, useMemo, useState } from 'react'
import { OrderStatus } from '../types'
import { RowActions } from '../components/common'
import StatCard from '../components/StatCard'
import FilterBar from '../components/FilterBar'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { colors } from '../constants'
import { useT } from '../i18n'
interface Props {
orderStatuses: OrderStatus[]
canModify?: boolean
onView?: (t: OrderStatus) => void
onEdit?: (t: OrderStatus) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: OrderStatus[], page: OrderStatus[]) => void
}
function fmtDate(d?: string) {
if (!d) return '—'
const dt = new Date(d)
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')}/${dt.getFullYear()}`
}
export default function OrderStatusPage({ orderStatuses: allOrderStatuses, canModify, onView, onEdit, onDelete, onSelectionChange }: Props) {
const tl = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }
const orderStatuses = useMemo(() => allOrderStatuses.filter(os => {
const label = (os.name1 || os.name || '').toLowerCase()
if (search && !label.includes(search.toLowerCase())) return false
if (statusFilter !== 'all' && (os.status || 'Active') !== statusFilter) return false
return true
}), [allOrderStatuses, search, statusFilter])
const [page, setPage] = useState(1)
useEffect(() => { setPage(1) }, [search, statusFilter])
const pageRows = orderStatuses.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = allOrderStatuses.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows])
return (
<>
<div style={{ display: 'flex', gap: 16 }}>
<StatCard label="Total" val={orderStatuses.length} color={colors.purple} />
</div>
<FilterBar filters={[
{ type: 'search', label: 'Search', value: search, onChange: setSearch, placeholder: 'Search by name…' },
{ type: 'select', label: 'Status', value: statusFilter, onChange: setStatusFilter, options: [
{ value: 'all', label: 'All' }, { value: 'Active', label: tl.field.statusActive }, { value: 'Inactive', label: tl.field.statusInactive },
] },
]} />
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8f9fa', position: 'sticky', top: 0 }}>
<th style={{ width: 40, padding: '10px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={th}>{tl.col.name}</th>
<th style={th}>{tl.field.notifyManager}</th>
<th style={th}>{tl.field.creationDate}</th>
<th style={th}>{tl.field.status}</th>
<th style={th}>{tl.col.actions}</th>
</tr>
</thead>
<tbody>
{orderStatuses.length === 0
? <tr><td colSpan={6} style={{ padding: 40, textAlign: 'center', color: '#aaa' }}>{tl.noData}</td></tr>
: pageRows.map((os, i) => (
<tr
key={os.name}
className="app-table-row"
onClick={() => onView?.(os)}
style={{
borderTop: '1px solid #f0f0f0',
background: selectedIds.has(os.name) ? '#eff6ff' : ((os as any).custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer'
}}
>
<td style={{ width: 40, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(os.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(os.name) ? next.delete(os.name) : next.add(os.name)
setSelectedIds(next)
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{ os.name1 || os.name || '—'}</td>
<td style={{ padding: '10px 14px' }}>{ os.notify_manager ? tl.field.statusActive : '—'}</td>
<td style={{ padding: '10px 14px', color: '#666' }}>{fmtDate(os.creation_date)}</td>
<td style={{ padding: '10px 14px' }}>
{(os as any).custom_is_deleted ? (
<span style={{ background: '#fee2e2', color: '#991b1b', padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 700 }}>
{tl.deletedBadge || 'DELETED'}
</span>
) : (
<span style={{ color: os.status === 'Inactive' ? '#e74c3c' : '#16a34a', fontWeight: 600 }}>
{ os.status === 'Inactive' ? tl.field.statusInactive : tl.field.statusActive}
</span>
)}
</td>
<td style={{ padding: '8px 14px' }} onClick={e => e.stopPropagation()}>
<RowActions onView={() => onView?.(os)} onEdit={canModify && onEdit && !(os as any).custom_is_deleted ? () => onEdit(os) : undefined} onDelete={canModify && onDelete && !(os as any).custom_is_deleted ? () => onDelete(os.name) : undefined} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={orderStatuses.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,94 @@
import { useT } from '../i18n'
import { Dispatch, SetStateAction } from 'react'
import { OrderType, User, Page } from '../types'
import { inp, lbl, FormCard, DeletedBanner } from '../components/common'
import DateInput from '../components/DateInput'
import FormPageLayout from '../components/FormPageLayout'
interface Props {
form: OrderType
setForm: Dispatch<SetStateAction<OrderType>>
formStatus: string
viewMode?: boolean
onEdit?: () => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
export default function OrderTypeFormPage({ form, setForm, formStatus, viewMode, onEdit, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile, onSubmit, onBack, onNavigate, onLogout, user }: Props) {
const t = useT()
const set = (patch: Partial<OrderType>) => setForm(f => ({ ...f, ...patch }))
const di: React.CSSProperties = viewMode ? { background: '#f8fafc' } : {}
const title = form.name
? (viewMode ? `Order Type: ${form.name1 || form.name}` : 'Edit Order Type')
: 'New Order Type'
return (
<FormPageLayout
title={title} activePage="order-types"
user={user} formStatus={formStatus}
buttonText={t.saveName} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{(form as any).custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={(form as any).custom_deleted_by}
deletedOn={(form as any).custom_deleted_on}
reason={(form as any).custom_deleted_reason}
onRestore={onRestore && form.name ? () => onRestore(form.name) : undefined}
onPermanentDelete={onPermanentDelete && form.name ? () => onPermanentDelete(form.name) : undefined}
/>
)}
<FormCard title="Order Type Details">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
<div>
<label style={lbl}>{t.field.name1} <span style={{ color: '#e74c3c' }}>*</span></label>
<input
style={{ ...inp, ...di }}
value={form.name1}
disabled={viewMode || !!form.name}
onChange={e => set({ name1: e.target.value })}
placeholder="Order type name"
required
/>
</div>
<div>
<label style={lbl}>{t.field.creationDate}</label>
<DateInput
value={form.creation_date || ''}
onChange={v => set({ creation_date: v })}
disabled={viewMode}
style={{ ...inp, ...di }}
/>
</div>
<div>
<label style={lbl}>{t.field.status}</label>
<select
style={{ ...inp, ...di }}
value={form.status || 'Active'}
disabled={viewMode}
onChange={e => set({ status: e.target.value })}
>
<option value="Active">{t.field.statusActive}</option>
<option value="Inactive">{t.field.statusInactive}</option>
</select>
</div>
</div>
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from 'react'
import { OrderType } from '../types'
import { RowActions } from '../components/common'
import StatCard from '../components/StatCard'
import FilterBar from '../components/FilterBar'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { colors } from '../constants'
import { useT } from '../i18n'
interface Props {
orderTypes: OrderType[]
canModify?: boolean
onView?: (t: OrderType) => void
onEdit?: (t: OrderType) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: OrderType[], page: OrderType[]) => void
}
function fmtDate(d?: string) {
if (!d) return '—'
const dt = new Date(d)
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')}/${dt.getFullYear()}`
}
export default function OrderTypePage({ orderTypes: allOrderTypes, canModify, onView, onEdit, onDelete, onSelectionChange }: Props) {
const t = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }
const orderTypes = useMemo(() => allOrderTypes.filter(ot => {
const label = (ot.name1 || ot.name || '').toLowerCase()
if (search && !label.includes(search.toLowerCase())) return false
if (statusFilter !== 'all' && (ot.status || 'Active') !== statusFilter) return false
return true
}), [allOrderTypes, search, statusFilter])
const [page, setPage] = useState(1)
useEffect(() => { setPage(1) }, [search, statusFilter])
const pageRows = orderTypes.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = allOrderTypes.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows])
return (
<>
<div style={{ display: 'flex', gap: 16 }}>
<StatCard label="Total" val={orderTypes.length} color={colors.purple} />
</div>
<FilterBar filters={[
{ type: 'search', label: 'Search', value: search, onChange: setSearch, placeholder: 'Search by name…' },
{ type: 'select', label: 'Status', value: statusFilter, onChange: setStatusFilter, options: [
{ value: 'all', label: 'All' }, { value: 'Active', label: t.field.statusActive }, { value: 'Inactive', label: t.field.statusInactive },
] },
]} />
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8f9fa', position: 'sticky', top: 0 }}>
<th style={{ width: 40, padding: '10px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={th}>{t.col.name}</th>
<th style={th}>{t.field.creationDate}</th>
<th style={th}>{t.field.status}</th>
<th style={th}>{t.col.actions}</th>
</tr>
</thead>
<tbody>
{orderTypes.length === 0
? <tr><td colSpan={5} style={{ padding: 40, textAlign: 'center', color: '#aaa' }}>{t.noData}</td></tr>
: pageRows.map((ot, i) => (
<tr
key={ot.name}
className="app-table-row"
onClick={() => onView?.(ot)}
style={{
borderTop: '1px solid #f0f0f0',
background: selectedIds.has(ot.name) ? '#eff6ff' : ((ot as any).custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer'
}}
>
<td style={{ width: 40, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(ot.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(ot.name) ? next.delete(ot.name) : next.add(ot.name)
setSelectedIds(next)
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{ ot.name1 || ot.name || '—'}</td>
<td style={{ padding: '10px 14px', color: '#666' }}>{fmtDate(ot.creation_date)}</td>
<td style={{ padding: '10px 14px' }}>
{(ot as any).custom_is_deleted ? (
<span style={{ background: '#fee2e2', color: '#991b1b', padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 700 }}>
{t.deletedBadge || 'DELETED'}
</span>
) : (
<span style={{ color: ot.status === 'Inactive' ? '#e74c3c' : '#16a34a', fontWeight: 600 }}>
{ ot.status === 'Inactive' ? t.field.statusInactive : t.field.statusActive}
</span>
)}
</td>
<td style={{ padding: '8px 14px' }} onClick={e => e.stopPropagation()}>
<RowActions onView={() => onView?.(ot)} onEdit={canModify && onEdit && !(ot as any).custom_is_deleted ? () => onEdit(ot) : undefined} onDelete={canModify && onDelete && !(ot as any).custom_is_deleted ? () => onDelete(ot.name) : undefined} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={orderTypes.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,198 @@
import { useEffect, useState } from 'react'
import { Order, Organization, Department, Branch, OrderStatus } from '../types'
import { typeBadge, statusBadge, RowActions, isWorkflowClosed } from '../components/common'
import FilterSection, { fLbl, fInp } from '../components/FilterSection'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { useT } from '../i18n'
interface Props {
orders: Order[]
organizations: Organization[]
departments: Department[]
branches: Branch[]
orderStatuses: OrderStatus[]
initialOrg?: string
initialStatus?: string
canModify?: boolean
userRole?: string
onView?: (o: Order) => void
onEdit?: (o: Order) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: Order[], page: Order[]) => void
}
export default function OrdersPage({
orders,
organizations,
departments,
branches,
orderStatuses,
initialOrg,
initialStatus,
canModify,
userRole,
onView,
onEdit,
onDelete,
onSelectionChange,
}: Props) {
const t = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [f, setF] = useState({
search: '',
organization: initialOrg || '',
department: '',
branch: '',
status: initialStatus || '',
})
const set = (k: string, v: string) => setF(p => ({ ...p, [k]: v }))
const [page, setPage] = useState(1)
const rows = orders.filter(ord => {
if (f.search && !`${ord.number} ${ord.title}`.toLowerCase().includes(f.search.toLowerCase())) return false
if (f.organization && ord.organization !== f.organization) return false
if (f.department && ord.department !== f.department) return false
if (f.branch && ord.branch !== f.branch) return false
if (f.status && ord.status !== f.status) return false
return true
})
useEffect(() => { setPage(1) }, [f])
const pageRows = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = orders.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows, orders])
return (
<>
<FilterSection>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '10px 14px' }}>
<div>
<label style={fLbl}>{t.filter.numberTitle}</label>
<input style={fInp} value={f.search} onChange={e => set('search', e.target.value)} placeholder={t.filter.searchPlaceholder} />
</div>
<div>
<label style={fLbl}>{t.filter.organization}</label>
<select style={fInp} value={f.organization} onChange={e => set('organization', e.target.value)}>
<option value="">{t.filter.allOrgs}</option>
{organizations.map(o => <option key={o.name} value={o.name}>{o.organization_name || o.name}</option>)}
</select>
</div>
<div>
<label style={fLbl}>{t.filter.department}</label>
<select style={fInp} value={f.department} onChange={e => set('department', e.target.value)}>
<option value="">{t.filter.allDepts}</option>
{departments.map(d => <option key={d.name} value={d.name}>{d.department_name || d.name}</option>)}
</select>
</div>
<div>
<label style={fLbl}>{t.filter.branch}</label>
<select style={fInp} value={f.branch} onChange={e => set('branch', e.target.value)}>
<option value="">{t.filter.allBranches}</option>
{branches.map(b => <option key={b.name} value={b.name}>{b.branch || b.name}</option>)}
</select>
</div>
<div>
<label style={fLbl}>{t.filter.status}</label>
<select style={fInp} value={f.status} onChange={e => set('status', e.target.value)}>
<option value="">{t.filter.allStatuses}</option>
{orderStatuses.map(s => <option key={s.name} value={s.name}>{s.name1 || s.name}</option>)}
</select>
</div>
</div>
</FilterSection>
<div style={{ background: 'white', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.06)', overflow: 'hidden', border: '1px solid #f1f5f9' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f8fafc', color: '#64748b', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>
<th style={{ width: 44, padding: '12px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.number}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.title}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.organization}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.type || 'Type'}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.status}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.actions}</th>
</tr>
</thead>
<tbody>
{rows.length === 0
? <tr><td colSpan={7} style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>{t.noOrders}</td></tr>
: pageRows.map((ord, i) => (
<tr
key={ord.name}
className="app-table-row"
onClick={() => onView?.(ord)}
style={{
borderTop: '1px solid #f1f5f9',
background: selectedIds.has(ord.name) ? '#eff6ff' : (ord.custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
>
<td style={{ width: 44, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(ord.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(ord.name) ? next.delete(ord.name) : next.add(ord.name)
setSelectedIds(next)
}}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '12px 16px', color: '#64748b', fontSize: 12, fontWeight: 500 }}>{ord.number || '—'}</td>
<td style={{ padding: '12px 16px', fontWeight: 600, color: '#1e293b' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span>{ord.title || ord.name || '—'}</span>
{ord.sb_idea && (
<span style={{ fontSize: 11, background: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', borderRadius: 9999, padding: '2px 8px', fontWeight: 600 }}>
💡 {ord.sb_idea}
</span>
)}
</div>
</td>
<td style={{ padding: '12px 16px', color: '#475569' }}>{ord.organization || '—'}</td>
<td style={{ padding: '12px 16px' }}>{ord.type ? typeBadge(ord.type) : '—'}</td>
<td style={{ padding: '12px 16px' }}>{ord.custom_is_deleted ? statusBadge(t.deletedBadge || 'DELETED') : (ord.status ? statusBadge(ord.status) : '—')}</td>
<td style={{ padding: '10px 16px' }} onClick={e => e.stopPropagation()}>
<RowActions
onView={() => onView?.(ord)}
onEdit={
canModify && onEdit && !ord.custom_is_deleted && !isWorkflowClosed('order', ord.workflow_state) && userRole !== 'Employee'
? () => onEdit(ord)
: undefined
}
onDelete={canModify && onDelete && !ord.custom_is_deleted ? () => onDelete(ord.name) : undefined}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={rows.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,139 @@
import { useT } from '../i18n'
import { Dispatch, SetStateAction } from 'react'
import { SBGroupCompany, User, Page } from '../types'
import { inp, lbl, FormCard, DeletedBanner } from '../components/common'
import FormPageLayout from '../components/FormPageLayout'
import DateInput from '../components/DateInput'
interface OrgForm {
name?: string
organization_name: string; type: string; city: string; contact_email: string
creation_date?: string; is_active?: number; acronym?: string; group_company?: string
incident_triage_window_days?: number; incident_resolution_window_days?: number
}
interface OrganizationFormPageProps {
orgForm: OrgForm
setOrgForm: Dispatch<SetStateAction<any>> | ((f: any) => void)
groupCompanies?: SBGroupCompany[]
formStatus: string
viewMode?: boolean
onEdit?: () => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
const disabledInp = (viewMode?: boolean): React.CSSProperties => ({
background: viewMode ? '#f9f9f9' : undefined
})
export default function OrganizationFormPage({
orgForm, setOrgForm, groupCompanies: _gc = [], formStatus, viewMode, onEdit, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile,
onSubmit, onBack, onNavigate, onLogout, user
}: OrganizationFormPageProps) {
const t = useT()
const title = orgForm.name
? (viewMode ? orgForm.organization_name || t.formTitle.editOrg : t.formTitle.editOrg)
: t.formTitle.newOrg
return (
<FormPageLayout
title={title}
activePage="organizations"
user={user}
formStatus={formStatus}
buttonText={t.saveOrg}
buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
onSubmit={onSubmit}
onBack={onBack}
onNavigate={onNavigate}
onLogout={onLogout}
>
{(orgForm as any).custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={(orgForm as any).custom_deleted_by}
deletedOn={(orgForm as any).custom_deleted_on}
reason={(orgForm as any).custom_deleted_reason}
onRestore={onRestore && orgForm.name ? () => onRestore(orgForm.name!) : undefined}
onPermanentDelete={onPermanentDelete && orgForm.name ? () => onPermanentDelete(orgForm.name!) : undefined}
/>
)}
<FormCard title="Organization Information">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16, marginBottom: 14 }}>
<div>
<label style={lbl}>{t.field.orgName} <span style={{ color: '#e74c3c' }}>*</span></label>
<input type="text" style={{ ...inp, ...disabledInp(viewMode || !!orgForm.name) }} value={orgForm.organization_name}
disabled={viewMode || !!orgForm.name} required
onChange={e => setOrgForm({ ...orgForm, organization_name: e.target.value })} />
</div>
<div>
<label style={lbl}>{t.field.orgAcronym} <span style={{ color: '#e74c3c' }}>*</span></label>
<input type="text" style={{ ...inp, ...disabledInp(viewMode || !!orgForm.name) }} value={orgForm.acronym || ''}
disabled={viewMode || !!orgForm.name} required
onChange={e => setOrgForm({ ...orgForm, acronym: e.target.value })} />
</div>
<div>
<label style={lbl}>{t.field.organizationType}</label>
<select style={{ ...inp, ...disabledInp(viewMode) }} value={orgForm.type} disabled={viewMode}
onChange={e => setOrgForm({ ...orgForm, type: e.target.value })}>
<option>Government</option><option>Private</option><option>Non-Profit</option>
</select>
</div>
<div>
<label style={lbl}>{t.field.orgCity}</label>
<input type="text" style={{ ...inp, ...disabledInp(viewMode) }} value={orgForm.city}
disabled={viewMode} onChange={e => setOrgForm({ ...orgForm, city: e.target.value })} />
</div>
<div>
<label style={lbl}>{t.field.contactEmail}</label>
<input type="email" style={{ ...inp, ...disabledInp(viewMode) }} value={orgForm.contact_email}
disabled={viewMode} onChange={e => setOrgForm({ ...orgForm, contact_email: e.target.value })} />
</div>
<div>
<label style={lbl}>{t.field.creationDate}</label>
<DateInput value={orgForm.creation_date || ''} onChange={v => setOrgForm({ ...orgForm, creation_date: v })}
disabled={viewMode} style={{ ...inp, ...disabledInp(viewMode) }} />
</div>
<div>
<label style={lbl}>{t.field.status}</label>
<select style={{ ...inp, ...disabledInp(viewMode) }} value={orgForm.is_active ? 'Active' : 'Inactive'}
disabled={viewMode} onChange={e => setOrgForm({ ...orgForm, is_active: e.target.value === 'Active' ? 1 : 0 })}>
<option value="Active">{t.field.statusActive}</option>
<option value="Inactive">{t.field.statusInactive}</option>
</select>
</div>
</div>
</FormCard>
<FormCard title="Incident SLA Windows">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 16 }}>
<div>
<label style={lbl}>{t.field.incidentTriageWindowDays}</label>
<input type="number" min={1} max={30} style={{ ...inp, ...disabledInp(viewMode) }}
value={orgForm.incident_triage_window_days ?? 3} disabled={viewMode}
onChange={e => setOrgForm({ ...orgForm, incident_triage_window_days: parseInt(e.target.value) || 3 })} />
<span style={{ fontSize: 11, color: '#64748b', display: 'block', marginTop: 4 }}>Days after creation before an untriaged Incident is flagged overdue</span>
</div>
<div>
<label style={lbl}>{t.field.incidentResolutionWindowDays}</label>
<input type="number" min={1} max={30} style={{ ...inp, ...disabledInp(viewMode) }}
value={orgForm.incident_resolution_window_days ?? 5} disabled={viewMode}
onChange={e => setOrgForm({ ...orgForm, incident_resolution_window_days: parseInt(e.target.value) || 5 })} />
<span style={{ fontSize: 11, color: '#64748b', display: 'block', marginTop: 4 }}>Days without an update before an in-progress Incident is flagged overdue</span>
</div>
</div>
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,149 @@
import { useEffect, useMemo, useState } from 'react'
import { colors } from '../constants'
import { Organization } from '../types'
import StatCard from '../components/StatCard'
import FilterBar from '../components/FilterBar'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { badge, RowActions } from '../components/common'
import { useT } from '../i18n'
interface OrganizationsPageProps {
organizations: Organization[]
canModify?: boolean
onView?: (organization: Organization) => void
onEdit?: (organization: Organization) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: Organization[], page: Organization[]) => void
}
function fmtDate(d?: string) {
if (!d) return '—'
const dt = new Date(d)
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')}/${dt.getFullYear()}`
}
export default function OrganizationsPage({ organizations: allOrganizations, canModify, onView, onEdit, onDelete, onSelectionChange }: OrganizationsPageProps) {
const t = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all')
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }
const organizations = useMemo(() => allOrganizations.filter(o => {
const label = [o.organization_name, o.city].filter(Boolean).join(' ').toLowerCase()
if (search && !label.includes(search.toLowerCase())) return false
if (typeFilter !== 'all' && o.type !== typeFilter) return false
if (statusFilter !== 'all' && (o.is_active ? 'Active' : 'Inactive') !== statusFilter) return false
return true
}), [allOrganizations, search, typeFilter, statusFilter])
const [page, setPage] = useState(1)
useEffect(() => { setPage(1) }, [search, typeFilter, statusFilter])
const pageRows = organizations.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = allOrganizations.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows])
return (
<>
<div style={{ display: 'flex', gap: 16 }}>
<StatCard label="Total" val={organizations.length} color="#1a2332" />
<StatCard label="Active" val={organizations.filter(o => o.is_active).length} color={colors.green} />
<StatCard label="Government" val={organizations.filter(o => o.type === 'Government').length} color={colors.blue} />
<StatCard label="Private" val={organizations.filter(o => o.type === 'Private').length} color={colors.orange} />
</div>
<FilterBar filters={[
{ type: 'search', label: 'Search', value: search, onChange: setSearch, placeholder: 'Name or city…' },
{ type: 'select', label: t.col.type, value: typeFilter, onChange: setTypeFilter, options: [
{ value: 'all', label: 'All' }, { value: 'Government', label: 'Government' }, { value: 'Private', label: 'Private' },
] },
{ type: 'select', label: 'Status', value: statusFilter, onChange: setStatusFilter, options: [
{ value: 'all', label: 'All' }, { value: 'Active', label: t.field.statusActive }, { value: 'Inactive', label: t.field.statusInactive },
] },
]} />
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8f9fa', position: 'sticky', top: 0 }}>
<th style={{ width: 40, padding: '10px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={th}>{t.col.name}</th>
<th style={th}>{t.col.type}</th>
<th style={th}>{t.col.city}</th>
<th style={th}>{t.field.creationDate}</th>
<th style={th}>{t.col.status}</th>
<th style={th}>{t.col.actions}</th>
</tr>
</thead>
<tbody>
{organizations.length === 0
? <tr><td colSpan={7} style={{ padding: 40, textAlign: 'center', color: '#aaa' }}>{t.noData}</td></tr>
: pageRows.map((org, i) => (
<tr
key={org.name}
className="app-table-row"
onClick={() => onView?.(org)}
style={{
borderTop: '1px solid #f0f0f0',
background: selectedIds.has(org.name) ? '#eff6ff' : ((org as any).custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer'
}}
>
<td style={{ width: 40, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(org.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(org.name) ? next.delete(org.name) : next.add(org.name)
setSelectedIds(next)
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{org.organization_name || '—'}</td>
<td style={{ padding: '10px 14px' }}>{org.type || '—'}</td>
<td style={{ padding: '10px 14px' }}>{org.city || '—'}</td>
<td style={{ padding: '10px 14px', color: '#666' }}>{fmtDate(org.creation_date)}</td>
<td style={{ padding: '10px 14px' }}>
{(org as any).custom_is_deleted ? (
<span style={{ background: '#fee2e2', color: '#991b1b', padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 700 }}>
{t.deletedBadge || 'DELETED'}
</span>
) : (
badge(org.is_active ? t.field.statusActive : t.field.statusInactive, org.is_active ? colors.green : colors.gray)
)}
</td>
<td style={{ padding: '8px 14px' }} onClick={e => e.stopPropagation()}>
<RowActions onView={() => onView?.(org)} onEdit={canModify && onEdit && !(org as any).custom_is_deleted ? () => onEdit(org) : undefined} onDelete={canModify && onDelete && !(org as any).custom_is_deleted ? () => onDelete(org.name) : undefined} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={organizations.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,575 @@
import { useT } from '../i18n'
import { DOCTYPES } from '../constants'
import { Dispatch, SetStateAction } from 'react'
import { Process, Organization, Management, Department, Branch, ProcessType, ProcessStatus, User, Page, SBCycle, ProcessCycleLogRow, SBGroupCompany } from '../types'
import { inp, lbl, usersWithRole, activeItems, filterOrgItems, DeletedBanner, DeleteRecordCard, FormCard, StatusSummaryCard } from '../components/common'
import DateInput from '../components/DateInput'
import FormPageLayout from '../components/FormPageLayout'
import WorkflowPanel from '../components/WorkflowPanel'
import OrgSelect from '../components/OrgSelect'
import LinkSelect from '../components/LinkSelect'
import CommentSection from '../components/CommentSection'
import ActivityLog from '../components/ActivityLog'
interface ProgressIndicatorRow {
name1: string
type: string
unit: string
cycle: string
target: string
maximum: string
minimum: string
manager: string
order?: string
actual_value?: number | ''
cycle_status?: string
cycle_date?: string
cycle_comment?: string
status?: string
}
interface ProcessFormPageProps {
processForm: Process
setProcessForm: Dispatch<SetStateAction<Process>>
organizations: Organization[]
groupCompanies?: SBGroupCompany[]
managements: Management[]
departments: Department[]
branches: Branch[]
processTypes: ProcessType[]
processStatuses: ProcessStatus[]
cycles?: SBCycle[]
users?: any[]
userRoleMap?: Record<string, string[]>
formStatus: string
viewMode?: boolean
canModify?: boolean
onEdit?: () => void
onDelete?: (name: string) => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
userRole?: string
onRefreshProcess?: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
export default function ProcessFormPage({
processForm, setProcessForm, organizations, groupCompanies: _gc = [], managements, departments, branches, processTypes, processStatuses: _processStatuses,
cycles = [], users = [], userRoleMap = {},
formStatus, viewMode, canModify, onEdit, onDelete, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile,
userRole, onRefreshProcess,
onSubmit, onBack, onNavigate, onLogout, user
}: ProcessFormPageProps) {
const tl = useT()
const set = (patch: Partial<Process>) => setProcessForm({ ...processForm, ...patch })
const di: React.CSSProperties = viewMode ? { background: '#f9f9f9' } : {}
const orgOptions = organizations
const orgLocked = !!user.custom_organization
const filteredMgmts = filterOrgItems(managements, { organization: processForm.organization })
const filteredDepts = filterOrgItems(departments, { organization: processForm.organization, management: processForm.management })
const filteredBranches = filterOrgItems(branches, { organization: processForm.organization, management: processForm.management, department: processForm.department })
const mgmtItems = activeItems(filteredMgmts, processForm.management).map(m => ({ name: m.name, label: m.name1 || m.name }))
const deptItems = activeItems(filteredDepts, processForm.department).map(d => ({ name: d.name, label: d.department_name || d.name }))
const branchItems = activeItems(filteredBranches, processForm.branch).map(b => ({ name: b.name, label: b.branch || b.name }))
const typeItems = activeItems(processTypes, processForm.type).map(pt => ({ name: pt.name, label: pt.name1 || pt.name }))
const cycleItems = activeItems(cycles, processForm.cycle).map(c => ({ name: c.name, label: c.name1 || c.name }))
const userItems = usersWithRole(users, userRoleMap, 'SB Employee', processForm.organization).map(u => ({ name: u.name, label: u.full_name || u.name }))
// Progress-indicator Manager column only offers Manager-category users of the process's org
const managerItems = usersWithRole(users, userRoleMap, 'SB Manager', processForm.organization).map(u => ({ name: u.name, label: u.full_name || u.name }))
const updateCycleLog = (index: number, patch: Partial<ProcessCycleLogRow>) => {
const next = [...(processForm.cycle_logs || [])]
next[index] = { ...next[index], ...patch }
set({ cycle_logs: next })
}
const addCycleLog = () => {
set({ cycle_logs: [...(processForm.cycle_logs || []), { cycle: '', cycle_date: '', status: 'Pass', actor: '', comment: '' }] })
}
const removeCycleLog = (index: number) => {
const next = [...(processForm.cycle_logs || [])]
next.splice(index, 1)
set({ cycle_logs: next })
}
const updateIndicator = (index: number, patch: Partial<ProgressIndicatorRow>) => {
const next = [...(processForm.progress_indicators || [])]
next[index] = { ...next[index], ...patch }
set({ progress_indicators: next })
}
const addIndicator = () => {
set({ progress_indicators: [...(processForm.progress_indicators || []), { name1: '', type: '', unit: '', cycle: '', target: '', maximum: '', minimum: '', manager: '', order: '', actual_value: '', cycle_status: 'Pass', cycle_date: '', cycle_comment: '', status: 'Active' }] })
}
const removeIndicator = (index: number) => {
const next = [...(processForm.progress_indicators || [])]
next.splice(index, 1)
set({ progress_indicators: next })
}
const title = processForm.name
? (viewMode ? `${tl.formTitle.viewProcess}${processForm.title || processForm.name}` : tl.formTitle.editProcess)
: tl.formTitle.newProcess
const canUpdateStatus = userRole !== 'Manager' || processForm.actor === user.name
const assignedActorName = userItems.find(u => u.name === processForm.actor)?.label || processForm.actor
const wfState = processForm.workflow_state || 'Draft'
const panel = processForm.name ? (
<div style={{ width: 240, display: 'flex', flexDirection: 'column', gap: 12 }}>
<WorkflowPanel
processName={processForm.name}
userRole={userRole || user.role || 'Employee'}
onRefresh={onRefreshProcess}
canUpdateStatus={canUpdateStatus}
/>
<StatusSummaryCard
priority={processForm.impact_level}
status={wfState}
assignedTo={assignedActorName}
createdDate={processForm.date_start}
modifiedDate={(processForm as any).modified}
/>
<CommentSection
doctype={DOCTYPES.processes}
docname={processForm.name}
currentUserName={user.name}
/>
<ActivityLog
doctype={DOCTYPES.processes}
docname={processForm.name}
docCreation={(processForm as any).creation}
docOwner={(processForm as any).owner}
/>
{canModify && onDelete && !processForm.custom_is_deleted && (
<DeleteRecordCard
title="Delete Request"
btnText={tl.delete || 'Delete'}
onDelete={() => onDelete(processForm.name)}
/>
)}
</div>
) : undefined
return (
<FormPageLayout
title={title}
activePage="processes"
user={user} formStatus={formStatus}
statusBadge={<span style={{ display: 'inline-block', padding: '4px 12px', borderRadius: 20, fontSize: 13, fontWeight: 700, background: '#eff6ff', color: '#1e40af', border: '1px solid #bfdbfe' }}>{wfState}</span>}
buttonText={tl.saveProcess} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
rightPanel={panel}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{processForm.custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={processForm.custom_deleted_by}
deletedOn={processForm.custom_deleted_on}
reason={processForm.custom_deleted_reason}
onRestore={onRestore ? () => onRestore(processForm.name) : undefined}
onPermanentDelete={onPermanentDelete ? () => onPermanentDelete(processForm.name) : undefined}
/>
)}
{/* Section 1: Process Information */}
<FormCard title="Process Information">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
{processForm.name && (
<div>
<label style={lbl}>{tl.field.number}</label>
<input style={{ ...inp, background: '#f8fafc', color: '#475569', fontWeight: 600 }} value={processForm.number || ''} readOnly />
</div>
)}
<div>
<label style={lbl}>{tl.field.organization} <span style={{ color: '#e74c3c' }}>*</span></label>
<OrgSelect
value={processForm.organization}
onChange={v => {
const orgCo = organizations.find(o => o.name === v)?.group_company
set({ organization: v, ...(user.custom_group_company ? {} : { group_company: orgCo || processForm.group_company }) })
}}
organizations={orgOptions}
disabled={viewMode || orgLocked}
userRole={userRole}
onQuickCreateSuccess={onRefreshProcess}
/>
</div>
<div>
<label style={lbl}>{tl.field.title}</label>
<input style={{ ...inp, ...di }} value={processForm.title} disabled={viewMode} onChange={e => set({ title: e.target.value })} />
</div>
<div>
<label style={lbl}>{tl.field.type}</label>
<LinkSelect
value={processForm.type}
onChange={v => set({ type: v })}
items={typeItems}
placeholder={tl.field.selectType}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.processTypes}
onQuickCreateSuccess={onRefreshProcess}
/>
</div>
<div>
<label style={lbl}>{tl.field.management}</label>
<LinkSelect
value={processForm.management}
onChange={v => set({ management: v })}
items={mgmtItems}
placeholder={tl.field.select}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.managements}
extraFields={{ organization: processForm.organization }}
onQuickCreateSuccess={onRefreshProcess}
/>
</div>
<div>
<label style={lbl}>{tl.field.department}</label>
<LinkSelect
value={processForm.department}
onChange={v => set({ department: v })}
items={deptItems}
placeholder={tl.field.select}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.departments}
extraFields={{ organization: processForm.organization, management: processForm.management }}
onQuickCreateSuccess={onRefreshProcess}
/>
</div>
<div>
<label style={lbl}>{tl.field.section}</label>
<input style={{ ...inp, ...di }} value={processForm.section_field || ''} disabled={viewMode} onChange={e => set({ section_field: e.target.value })} />
</div>
<div>
<label style={lbl}>{tl.field.branch}</label>
<LinkSelect
value={processForm.branch}
onChange={v => set({ branch: v })}
items={branchItems}
placeholder={tl.field.select}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.branches}
extraFields={{ organization: processForm.organization, management: processForm.management, department: processForm.department }}
onQuickCreateSuccess={onRefreshProcess}
/>
</div>
</div>
</FormCard>
{/* Section 2: Classification */}
<FormCard title="Classification">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 12 }}>
<div>
<label style={lbl}>{tl.field.chainLocation}</label>
<select style={{ ...inp, ...di }} value={processForm.chain_location || ''} disabled={viewMode} onChange={e => set({ chain_location: e.target.value })}>
<option value="">{tl.field.select}</option>
<option value="Upstream">Upstream</option>
<option value="On-stream">On-stream</option>
<option value="Downstream">Downstream</option>
</select>
</div>
<div>
<label style={lbl}>{tl.field.economyClass}</label>
<select style={{ ...inp, ...di }} value={processForm.economy_class || ''} disabled={viewMode} onChange={e => set({ economy_class: e.target.value })}>
<option value="">{tl.field.select}</option>
<option value="Expenses">{tl.field.expenses}</option>
<option value="Capital">{tl.field.capital}</option>
<option value="Earn">{tl.field.earn}</option>
<option value="Pay/Loss">{tl.field.payLoss}</option>
</select>
</div>
<div>
<label style={lbl}>{tl.field.impactLevel}</label>
<select style={{ ...inp, ...di }} value={processForm.impact_level || ''} disabled={viewMode} onChange={e => set({ impact_level: e.target.value })}>
<option value="">{tl.field.select}</option>
<option value="High">{tl.field.high}</option>
<option value="Normal">{tl.field.normal}</option>
</select>
</div>
<div>
<label style={lbl}>{tl.field.entityScope}</label>
<select style={{ ...inp, ...di }} value={processForm.entity_scope || ''} disabled={viewMode} onChange={e => set({ entity_scope: e.target.value })}>
<option value="">{tl.field.select}</option>
<option value="Upstream">{tl.field.upstream}</option>
<option value="On-stream">{tl.field.onstream}</option>
<option value="Downstream">{tl.field.downstream}</option>
</select>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16, alignItems: 'center' }}>
<div>
<label style={lbl}>{tl.field.hsesField}</label>
<input style={{ ...inp, ...di }} value={processForm.hses_field || ''} disabled={viewMode} onChange={e => set({ hses_field: e.target.value })} placeholder="Safety, Health, Environment…" />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingTop: 18 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
<input type="checkbox" checked={!!processForm.product_relation} disabled={viewMode}
onChange={e => set({ product_relation: e.target.checked ? 1 : 0 })} /> {tl.field.productRelation}
</label>
</div>
</div>
</FormCard>
{/* Section 3: KPI & Timeline */}
<FormCard title="KPI & Timeline">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 14 }}>
<div>
<label style={lbl}>{tl.field.kpiName}</label>
<input style={{ ...inp, ...di }} value={processForm.kpi_name || ''} disabled={viewMode} onChange={e => set({ kpi_name: e.target.value })} />
</div>
<div>
<label style={lbl}>{tl.field.kpiType}</label>
<select style={{ ...inp, ...di }} value={processForm.kpi_type || ''} disabled={viewMode} onChange={e => set({ kpi_type: e.target.value })}>
<option value="">{tl.field.select}</option>
<option value="Human">Human</option>
<option value="Substantial">Substantial</option>
<option value="System">System</option>
<option value="Value">Value</option>
</select>
</div>
<div>
<label style={lbl}>{tl.field.kpiMeasure}</label>
<input style={{ ...inp, ...di }} value={processForm.kpi_measure || ''} disabled={viewMode} onChange={e => set({ kpi_measure: e.target.value })} placeholder="Unit of measure" />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16 }}>
<div><label style={lbl}>{tl.field.dateStart}</label><DateInput value={processForm.date_start} onChange={v => set({ date_start: v })} disabled={viewMode} style={{ ...inp, ...di }} /></div>
<div><label style={lbl}>{tl.field.dateEnd}</label><DateInput value={processForm.date_end} onChange={v => set({ date_end: v })} disabled={viewMode} style={{ ...inp, ...di }} /></div>
<div>
<label style={lbl}>{tl.field.cycleType}</label>
<select style={{ ...inp, ...di }} value={processForm.cycle_type || ''} disabled={viewMode} onChange={e => set({ cycle_type: e.target.value })}>
<option value="">{tl.field.select}</option>
<option value="Normal">Normal</option>
<option value="Long">Long</option>
</select>
</div>
</div>
{/* Long cycle dates shown when cycle_type = Long */}
{processForm.cycle_type === 'Long' && (
<div style={{ background: '#fff7ed', border: '1px solid #fed7aa', borderRadius: 8, padding: 14, marginTop: 14 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: '#c2410c', marginBottom: 10 }}>Long Cycle Milestone Dates</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
<div><label style={lbl}>Milestone 1</label><DateInput value={processForm.long_cycle_date_1 || ''} onChange={v => set({ long_cycle_date_1: v })} disabled={viewMode} style={{ ...inp, ...di }} /></div>
<div><label style={lbl}>Milestone 2</label><DateInput value={processForm.long_cycle_date_2 || ''} onChange={v => set({ long_cycle_date_2: v })} disabled={viewMode} style={{ ...inp, ...di }} /></div>
<div><label style={lbl}>Milestone 3</label><DateInput value={processForm.long_cycle_date_3 || ''} onChange={v => set({ long_cycle_date_3: v })} disabled={viewMode} style={{ ...inp, ...di }} /></div>
<div><label style={lbl}>Milestone 4</label><DateInput value={processForm.long_cycle_date_4 || ''} onChange={v => set({ long_cycle_date_4: v })} disabled={viewMode} style={{ ...inp, ...di }} /></div>
</div>
</div>
)}
</FormCard>
{/* Section 4: Progress Indicators */}
<FormCard title="Progress Indicators">
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
{!viewMode && (
<button type="button" onClick={addIndicator} style={{ padding: '7px 14px', border: 'none', background: '#2563eb', color: 'white', borderRadius: 6, fontWeight: 600, fontSize: 12, cursor: 'pointer' }}>
+ Add Row
</button>
)}
</div>
<div style={{ overflowX: 'auto', minWidth: 0 }}>
<table style={{ borderCollapse: 'collapse', fontSize: 12, width: 'max-content', minWidth: '100%' }}>
<thead>
<tr style={{ background: '#f8fafc' }}>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Order</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Name</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Type</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Unit</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Cycle</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Target</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Min</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Max</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Manager</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Actual Value</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Cycle Status</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Cycle Date</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Cycle Comment</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Status</th>
{!viewMode && <th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Actions</th>}
</tr>
</thead>
<tbody>
{(processForm.progress_indicators || []).length === 0 && (
<tr><td colSpan={viewMode ? 14 : 15} style={{ padding: 18, textAlign: 'center', color: '#94a3b8' }}>No progress indicators</td></tr>
)}
{(processForm.progress_indicators || []).map((indicator, i) => (
<tr key={i} style={{ background: i % 2 === 0 ? 'white' : '#fafafa', borderBottom: '1px solid #f1f5f9' }}>
<td style={{ padding: '8px 10px' }}><input style={{ ...inp, ...di, width: 60 }} value={indicator.order ?? ''} disabled={viewMode} onChange={e => updateIndicator(i, { order: e.target.value })} /></td>
<td style={{ padding: '8px 10px' }}><input style={{ ...inp, ...di }} value={indicator.name1} disabled={viewMode} onChange={e => updateIndicator(i, { name1: e.target.value })} /></td>
<td style={{ padding: '8px 10px' }}>
<select style={{ ...inp, ...di }} value={indicator.type} disabled={viewMode} onChange={e => updateIndicator(i, { type: e.target.value })}>
<option>Nominal</option><option>Ordinal</option><option>Interval</option><option>Ratio</option>
</select>
</td>
<td style={{ padding: '8px 10px' }}><input style={{ ...inp, ...di, width: 70 }} type="text" value={indicator.unit ?? ''} disabled={viewMode} onChange={e => updateIndicator(i, { unit: e.target.value })} placeholder="e.g. °C, kg, %" /></td>
<td style={{ padding: '8px 10px' }}>
<select style={{ ...inp, ...di, minWidth: 120 }} value={indicator.cycle} disabled={viewMode} onChange={e => updateIndicator(i, { cycle: e.target.value })}>
<option value="">--</option>
{cycleItems.map(c => <option key={c.name} value={c.name}>{c.label}</option>)}
{indicator.cycle && !cycleItems.some(c => c.name === indicator.cycle) && <option value={indicator.cycle}>{indicator.cycle}</option>}
</select>
</td>
<td style={{ padding: '8px 10px' }}><input style={{ ...inp, ...di, width: 80 }} value={indicator.target} disabled={viewMode} onChange={e => updateIndicator(i, { target: e.target.value })} /></td>
<td style={{ padding: '8px 10px' }}><input style={{ ...inp, ...di, width: 80 }} value={indicator.minimum} disabled={viewMode} onChange={e => updateIndicator(i, { minimum: e.target.value })} /></td>
<td style={{ padding: '8px 10px' }}><input style={{ ...inp, ...di, width: 80 }} value={indicator.maximum} disabled={viewMode} onChange={e => updateIndicator(i, { maximum: e.target.value })} /></td>
<td style={{ padding: '8px 10px' }}>
<select style={{ ...inp, ...di, minWidth: 140 }} value={indicator.manager} disabled={viewMode} onChange={e => updateIndicator(i, { manager: e.target.value })}>
<option value="">--</option>
{managerItems.map(m => <option key={m.name} value={m.name}>{m.label}</option>)}
{indicator.manager && !managerItems.some(m => m.name === indicator.manager) && <option value={indicator.manager}>{indicator.manager}</option>}
</select>
</td>
<td style={{ padding: '8px 10px' }}><input style={{ ...inp, ...di, width: 90 }} type="number" value={indicator.actual_value ?? ''} disabled={viewMode} onChange={e => updateIndicator(i, { actual_value: parseFloat(e.target.value) || '' })} /></td>
<td style={{ padding: '8px 10px' }}>
<select style={{ ...inp, ...di }} value={indicator.cycle_status ?? ''} disabled={viewMode} onChange={e => updateIndicator(i, { cycle_status: e.target.value })}>
<option value="">--</option><option>Normal</option><option>Below Min</option><option>Above Max</option><option>At Risk</option>
</select>
</td>
<td style={{ padding: '8px 10px' }}><DateInput value={indicator.cycle_date || ''} onChange={v => updateIndicator(i, { cycle_date: v })} disabled={viewMode} style={{ ...inp, ...di, width: 110 }} /></td>
<td style={{ padding: '8px 10px' }}><input style={{ ...inp, ...di }} value={indicator.cycle_comment ?? ''} disabled={viewMode} onChange={e => updateIndicator(i, { cycle_comment: e.target.value })} /></td>
<td style={{ padding: '8px 10px' }}>
<select style={{ ...inp, ...di }} value={indicator.status ?? ''} disabled={viewMode} onChange={e => updateIndicator(i, { status: e.target.value })}>
<option value="">--</option><option>Normal</option><option>Below Min</option><option>Above Max</option><option>At Risk</option>
</select>
</td>
{!viewMode && (
<td style={{ padding: '8px 10px' }}>
<button type="button" onClick={() => removeIndicator(i)} style={{ padding: '4px 8px', border: '1px solid #fecaca', background: '#fef2f2', color: '#dc2626', borderRadius: 4, cursor: 'pointer', fontSize: 11, fontWeight: 600 }}>
Remove
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</FormCard>
{/* Section 5: Cycle & Assignment */}
<FormCard title="Cycle & Assignment">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 12 }}>
<div>
<label style={lbl}>{tl.field.cycle}</label>
<LinkSelect
value={processForm.cycle || ''}
onChange={v => set({ cycle: v })}
items={cycleItems}
placeholder={tl.field.select}
disabled={viewMode}
userRole={userRole}
doctypeName={DOCTYPES.cycles}
extraFields={{ organization: processForm.organization }}
onQuickCreateSuccess={onRefreshProcess}
/>
</div>
<div>
<label style={lbl}>{tl.field.actor}</label>
<select style={{ ...inp, ...di }} value={processForm.actor || ''} disabled={viewMode} onChange={e => set({ actor: e.target.value })}>
<option value="">{tl.field.select}</option>
{userItems.map(u => <option key={u.name} value={u.name}>{u.label}</option>)}
</select>
</div>
<div>
<label style={lbl}>{tl.field.costPerCycle}</label>
<input style={{ ...inp, ...di }} type="number" value={processForm.cost_per_cycle || ''} disabled={viewMode} onChange={e => set({ cost_per_cycle: parseFloat(e.target.value) || 0 })} placeholder="0.00" />
</div>
<div>
<label style={lbl}>{tl.field.escalationLevel}</label>
<input style={{ ...inp, ...di }} type="number" value={processForm.escalation_level || ''} disabled={viewMode} onChange={e => set({ escalation_level: parseInt(e.target.value) || 0 })} />
</div>
</div>
</FormCard>
{/* Section 6: Cycle History */}
<FormCard title="Cycle History">
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
{!viewMode && (
<button type="button" onClick={addCycleLog} style={{ padding: '7px 14px', border: 'none', background: '#2563eb', color: 'white', borderRadius: 6, fontWeight: 600, fontSize: 12, cursor: 'pointer' }}>
+ Add Row
</button>
)}
</div>
<div style={{ overflowX: 'auto', minWidth: 0 }}>
<table style={{ borderCollapse: 'collapse', fontSize: 12, width: 'max-content', minWidth: '100%' }}>
<thead>
<tr style={{ background: '#f8fafc' }}>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Cycle</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Date</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Status</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Actor</th>
<th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Comment</th>
{!viewMode && <th style={{ padding: '10px 12px', textAlign: 'left', color: '#475569', fontWeight: 600, borderBottom: '1px solid #e2e8f0' }}>Actions</th>}
</tr>
</thead>
<tbody>
{(processForm.cycle_logs || []).length === 0 && (
<tr><td colSpan={viewMode ? 5 : 6} style={{ padding: 18, textAlign: 'center', color: '#94a3b8' }}>No cycle history</td></tr>
)}
{(processForm.cycle_logs || []).map((log, i) => (
<tr key={i} style={{ background: i % 2 === 0 ? 'white' : '#fafafa', borderBottom: '1px solid #f1f5f9' }}>
<td style={{ padding: '8px 10px' }}>
<select style={{ ...inp, ...di }} value={log.cycle || ''} disabled={viewMode} onChange={e => updateCycleLog(i, { cycle: e.target.value })}>
<option value="">-- Select --</option>
{cycleItems.map(c => <option key={c.name} value={c.name}>{c.label}</option>)}
</select>
</td>
<td style={{ padding: '8px 10px' }}><DateInput value={log.cycle_date || ''} onChange={v => updateCycleLog(i, { cycle_date: v })} disabled={viewMode} style={{ ...inp, ...di, width: 130 }} /></td>
<td style={{ padding: '8px 10px' }}>
<select style={{ ...inp, ...di }} value={log.status} disabled={viewMode} onChange={e => updateCycleLog(i, { status: e.target.value as any })}>
<option value="Pass">Pass</option><option value="Fail">Fail</option><option value="Unknown">Unknown</option>
</select>
</td>
<td style={{ padding: '8px 10px' }}>
<select style={{ ...inp, ...di }} value={log.actor || ''} disabled={viewMode} onChange={e => updateCycleLog(i, { actor: e.target.value })}>
<option value="">-- Select --</option>
{userItems.map(u => <option key={u.name} value={u.name}>{u.label}</option>)}
</select>
</td>
<td style={{ padding: '8px 10px' }}><input style={{ ...inp, ...di }} value={log.comment || ''} disabled={viewMode} onChange={e => updateCycleLog(i, { comment: e.target.value })} /></td>
{!viewMode && (
<td style={{ padding: '8px 10px' }}>
<button type="button" onClick={() => removeCycleLog(i)} style={{ padding: '4px 8px', border: '1px solid #fecaca', background: '#fef2f2', color: '#dc2626', borderRadius: 4, cursor: 'pointer', fontSize: 11, fontWeight: 600 }}>
Remove
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</FormCard>
{/* Section 7: Auditor Comment */}
<FormCard title={tl.field.auditorComment}>
<textarea style={{ ...inp, height: 70, resize: 'vertical', background: '#f8fafc' }}
value={processForm.auditor_comment || ''} disabled
readOnly placeholder={tl.field.commentPlaceholder} />
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,108 @@
import { useT } from '../i18n'
import { Dispatch, SetStateAction } from 'react'
import { ProcessStatus, User, Page } from '../types'
import { inp, lbl, chk, FormCard, DeletedBanner } from '../components/common'
import DateInput from '../components/DateInput'
import FormPageLayout from '../components/FormPageLayout'
interface Props {
form: ProcessStatus
setForm: Dispatch<SetStateAction<ProcessStatus>>
formStatus: string
viewMode?: boolean
onEdit?: () => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
export default function ProcessStatusFormPage({ form, setForm, formStatus, viewMode, onEdit, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile, onSubmit, onBack, onNavigate, onLogout, user }: Props) {
const t = useT()
const set = (patch: Partial<ProcessStatus>) => setForm(f => ({ ...f, ...patch }))
const di: React.CSSProperties = viewMode ? { background: '#f8fafc' } : {}
const title = form.name
? (viewMode ? `Process Status: ${form.name1 || form.name}` : 'Edit Process Status')
: 'New Process Status'
return (
<FormPageLayout
title={title} activePage="process-statuses"
user={user} formStatus={formStatus}
buttonText={t.saveName} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{(form as any).custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={(form as any).custom_deleted_by}
deletedOn={(form as any).custom_deleted_on}
reason={(form as any).custom_deleted_reason}
onRestore={onRestore && form.name ? () => onRestore(form.name) : undefined}
onPermanentDelete={onPermanentDelete && form.name ? () => onPermanentDelete(form.name) : undefined}
/>
)}
<FormCard title="Process Status Details">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16, marginBottom: 14 }}>
<div>
<label style={lbl}>{t.field.name1} <span style={{ color: '#e74c3c' }}>*</span></label>
<input
style={{ ...inp, ...di }}
value={form.name1}
disabled={viewMode || !!form.name}
onChange={e => set({ name1: e.target.value })}
placeholder="Process status name"
required
/>
</div>
<div>
<label style={lbl}>{t.field.creationDate}</label>
<DateInput
value={form.creation_date || ''}
onChange={v => set({ creation_date: v })}
disabled={viewMode}
style={{ ...inp, ...di }}
/>
</div>
<div>
<label style={lbl}>{t.field.status}</label>
<select
style={{ ...inp, ...di }}
value={form.status || 'Active'}
disabled={viewMode}
onChange={e => set({ status: e.target.value })}
>
<option value="Active">{t.field.statusActive}</option>
<option value="Inactive">{t.field.statusInactive}</option>
</select>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
id="notify_manager"
checked={!!form.notify_manager}
disabled={viewMode}
onChange={e => set({ notify_manager: e.target.checked ? 1 : 0 })}
style={chk}
/>
<label htmlFor="notify_manager" style={{ fontSize: 13, cursor: viewMode ? 'default' : 'pointer' }}>
{t.field.notifyManager}
</label>
</div>
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,141 @@
import { useEffect, useMemo, useState } from 'react'
import { ProcessStatus } from '../types'
import { RowActions } from '../components/common'
import StatCard from '../components/StatCard'
import FilterBar from '../components/FilterBar'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { colors } from '../constants'
import { useT } from '../i18n'
interface Props {
processStatuses: ProcessStatus[]
canModify?: boolean
onView?: (t: ProcessStatus) => void
onEdit?: (t: ProcessStatus) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: ProcessStatus[], page: ProcessStatus[]) => void
}
function fmtDate(d?: string) {
if (!d) return '—'
const dt = new Date(d)
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')}/${dt.getFullYear()}`
}
export default function ProcessStatusPage({ processStatuses: allProcessStatuses, canModify, onView, onEdit, onDelete, onSelectionChange }: Props) {
const tl = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }
const processStatuses = useMemo(() => allProcessStatuses.filter(ps => {
const label = (ps.name1 || ps.name || '').toLowerCase()
if (search && !label.includes(search.toLowerCase())) return false
if (statusFilter !== 'all' && (ps.status || 'Active') !== statusFilter) return false
return true
}), [allProcessStatuses, search, statusFilter])
const [page, setPage] = useState(1)
useEffect(() => { setPage(1) }, [search, statusFilter])
const pageRows = processStatuses.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = allProcessStatuses.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows])
return (
<>
<div style={{ display: 'flex', gap: 16 }}>
<StatCard label="Total" val={processStatuses.length} color={colors.blue} />
</div>
<FilterBar filters={[
{ type: 'search', label: 'Search', value: search, onChange: setSearch, placeholder: 'Search by name…' },
{ type: 'select', label: 'Status', value: statusFilter, onChange: setStatusFilter, options: [
{ value: 'all', label: 'All' }, { value: 'Active', label: tl.field.statusActive }, { value: 'Inactive', label: tl.field.statusInactive },
] },
]} />
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8f9fa', position: 'sticky', top: 0 }}>
<th style={{ width: 40, padding: '10px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={th}>{tl.col.name}</th>
<th style={th}>{tl.field.notifyManager}</th>
<th style={th}>{tl.field.creationDate}</th>
<th style={th}>{tl.field.status}</th>
<th style={th}>{tl.col.actions}</th>
</tr>
</thead>
<tbody>
{processStatuses.length === 0
? <tr><td colSpan={6} style={{ padding: 40, textAlign: 'center', color: '#aaa' }}>{tl.noData}</td></tr>
: pageRows.map((ps, i) => (
<tr
key={ps.name}
className="app-table-row"
onClick={() => onView?.(ps)}
style={{
borderTop: '1px solid #f0f0f0',
background: selectedIds.has(ps.name) ? '#eff6ff' : ((ps as any).custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer'
}}
>
<td style={{ width: 40, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(ps.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(ps.name) ? next.delete(ps.name) : next.add(ps.name)
setSelectedIds(next)
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{ ps.name1 || ps.name || '—'}</td>
<td style={{ padding: '10px 14px' }}>{ ps.notify_manager ? tl.field.statusActive : '—'}</td>
<td style={{ padding: '10px 14px', color: '#666' }}>{fmtDate(ps.creation_date)}</td>
<td style={{ padding: '10px 14px' }}>
{(ps as any).custom_is_deleted ? (
<span style={{ background: '#fee2e2', color: '#991b1b', padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 700 }}>
{tl.deletedBadge || 'DELETED'}
</span>
) : (
<span style={{ color: ps.status === 'Inactive' ? '#e74c3c' : '#16a34a', fontWeight: 600 }}>
{ ps.status === 'Inactive' ? tl.field.statusInactive : tl.field.statusActive}
</span>
)}
</td>
<td style={{ padding: '8px 14px' }} onClick={e => e.stopPropagation()}>
<RowActions onView={() => onView?.(ps)} onEdit={canModify && onEdit && !(ps as any).custom_is_deleted ? () => onEdit(ps) : undefined} onDelete={canModify && onDelete && !(ps as any).custom_is_deleted ? () => onDelete(ps.name) : undefined} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={processStatuses.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,94 @@
import { useT } from '../i18n'
import { Dispatch, SetStateAction } from 'react'
import { ProcessType, User, Page } from '../types'
import { inp, lbl, FormCard, DeletedBanner } from '../components/common'
import DateInput from '../components/DateInput'
import FormPageLayout from '../components/FormPageLayout'
interface Props {
form: ProcessType
setForm: Dispatch<SetStateAction<ProcessType>>
formStatus: string
viewMode?: boolean
onEdit?: () => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
export default function ProcessTypeFormPage({ form, setForm, formStatus, viewMode, onEdit, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile, onSubmit, onBack, onNavigate, onLogout, user }: Props) {
const t = useT()
const set = (patch: Partial<ProcessType>) => setForm(f => ({ ...f, ...patch }))
const di: React.CSSProperties = viewMode ? { background: '#f8fafc' } : {}
const title = form.name
? (viewMode ? `Process Type: ${form.name1 || form.name}` : 'Edit Process Type')
: 'New Process Type'
return (
<FormPageLayout
title={title} activePage="process-types"
user={user} formStatus={formStatus}
buttonText={t.saveName} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
onSubmit={onSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{(form as any).custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={(form as any).custom_deleted_by}
deletedOn={(form as any).custom_deleted_on}
reason={(form as any).custom_deleted_reason}
onRestore={onRestore && form.name ? () => onRestore(form.name) : undefined}
onPermanentDelete={onPermanentDelete && form.name ? () => onPermanentDelete(form.name) : undefined}
/>
)}
<FormCard title="Process Type Details">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
<div>
<label style={lbl}>{t.field.name1} <span style={{ color: '#e74c3c' }}>*</span></label>
<input
style={{ ...inp, ...di }}
value={form.name1}
disabled={viewMode || !!form.name}
onChange={e => set({ name1: e.target.value })}
placeholder="Process type name"
required
/>
</div>
<div>
<label style={lbl}>{t.field.creationDate}</label>
<DateInput
value={form.creation_date || ''}
onChange={v => set({ creation_date: v })}
disabled={viewMode}
style={{ ...inp, ...di }}
/>
</div>
<div>
<label style={lbl}>{t.field.status}</label>
<select
style={{ ...inp, ...di }}
value={form.status || 'Active'}
disabled={viewMode}
onChange={e => set({ status: e.target.value })}
>
<option value="Active">{t.field.statusActive}</option>
<option value="Inactive">{t.field.statusInactive}</option>
</select>
</div>
</div>
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from 'react'
import { ProcessType } from '../types'
import { RowActions } from '../components/common'
import StatCard from '../components/StatCard'
import FilterBar from '../components/FilterBar'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { colors } from '../constants'
import { useT } from '../i18n'
interface Props {
processTypes: ProcessType[]
canModify?: boolean
onView?: (t: ProcessType) => void
onEdit?: (t: ProcessType) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: ProcessType[], page: ProcessType[]) => void
}
function fmtDate(d?: string) {
if (!d) return '—'
const dt = new Date(d)
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')}/${dt.getFullYear()}`
}
export default function ProcessTypePage({ processTypes: allProcessTypes, canModify, onView, onEdit, onDelete, onSelectionChange }: Props) {
const t = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 10, fontWeight: 700 }
const processTypes = useMemo(() => allProcessTypes.filter(pt => {
const label = (pt.name1 || pt.name || '').toLowerCase()
if (search && !label.includes(search.toLowerCase())) return false
if (statusFilter !== 'all' && (pt.status || 'Active') !== statusFilter) return false
return true
}), [allProcessTypes, search, statusFilter])
const [page, setPage] = useState(1)
useEffect(() => { setPage(1) }, [search, statusFilter])
const pageRows = processTypes.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = allProcessTypes.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows])
return (
<>
<div style={{ display: 'flex', gap: 16 }}>
<StatCard label="Total" val={processTypes.length} color={colors.blue} />
</div>
<FilterBar filters={[
{ type: 'search', label: 'Search', value: search, onChange: setSearch, placeholder: 'Search by name…' },
{ type: 'select', label: 'Status', value: statusFilter, onChange: setStatusFilter, options: [
{ value: 'all', label: 'All' }, { value: 'Active', label: t.field.statusActive }, { value: 'Inactive', label: t.field.statusInactive },
] },
]} />
<div style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8f9fa', position: 'sticky', top: 0 }}>
<th style={{ width: 40, padding: '10px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={th}>{t.col.name}</th>
<th style={th}>{t.field.creationDate}</th>
<th style={th}>{t.field.status}</th>
<th style={th}>{t.col.actions}</th>
</tr>
</thead>
<tbody>
{processTypes.length === 0
? <tr><td colSpan={5} style={{ padding: 40, textAlign: 'center', color: '#aaa' }}>{t.noData}</td></tr>
: pageRows.map((pt, i) => (
<tr
key={pt.name}
className="app-table-row"
onClick={() => onView?.(pt)}
style={{
borderTop: '1px solid #f0f0f0',
background: selectedIds.has(pt.name) ? '#eff6ff' : ((pt as any).custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer'
}}
>
<td style={{ width: 40, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(pt.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(pt.name) ? next.delete(pt.name) : next.add(pt.name)
setSelectedIds(next)
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{ pt.name1 || pt.name || '—'}</td>
<td style={{ padding: '10px 14px', color: '#666' }}>{fmtDate(pt.creation_date)}</td>
<td style={{ padding: '10px 14px' }}>
{(pt as any).custom_is_deleted ? (
<span style={{ background: '#fee2e2', color: '#991b1b', padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 700 }}>
{t.deletedBadge || 'DELETED'}
</span>
) : (
<span style={{ color: pt.status === 'Inactive' ? '#e74c3c' : '#16a34a', fontWeight: 600 }}>
{ pt.status === 'Inactive' ? t.field.statusInactive : t.field.statusActive}
</span>
)}
</td>
<td style={{ padding: '8px 14px' }} onClick={e => e.stopPropagation()}>
<RowActions onView={() => onView?.(pt)} onEdit={canModify && onEdit && !(pt as any).custom_is_deleted ? () => onEdit(pt) : undefined} onDelete={canModify && onDelete && !(pt as any).custom_is_deleted ? () => onDelete(pt.name) : undefined} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={processTypes.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,189 @@
import { useEffect, useState } from 'react'
import { Process, Organization, Department, Branch, ProcessStatus } from '../types'
import { typeBadge, statusBadge, RowActions, isWorkflowClosed } from '../components/common'
import FilterSection, { fLbl, fInp } from '../components/FilterSection'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
import { useT } from '../i18n'
interface Props {
processes: Process[]
organizations: Organization[]
departments: Department[]
branches: Branch[]
processStatuses: ProcessStatus[]
initialOrg?: string
initialStatus?: string
canModify?: boolean
userRole?: string
onView?: (p: Process) => void
onEdit?: (p: Process) => void
onDelete?: (name: string) => void
onSelectionChange?: (selected: Process[], page: Process[]) => void
}
export default function ProcessesPage({
processes,
organizations,
departments,
branches,
processStatuses,
initialOrg,
initialStatus,
canModify,
userRole,
onView,
onEdit,
onDelete,
onSelectionChange,
}: Props) {
const t = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [f, setF] = useState({
search: '',
organization: initialOrg || '',
department: '',
branch: '',
status: initialStatus || '',
})
const set = (k: string, v: string) => setF(p => ({ ...p, [k]: v }))
const [page, setPage] = useState(1)
const rows = processes.filter(proc => {
if (f.search && !`${proc.number} ${proc.title}`.toLowerCase().includes(f.search.toLowerCase())) return false
if (f.organization && proc.organization !== f.organization) return false
if (f.department && proc.department !== f.department) return false
if (f.branch && proc.branch !== f.branch) return false
if (f.status && proc.status !== f.status) return false
return true
})
useEffect(() => { setPage(1) }, [f])
const pageRows = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = processes.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageRows)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageRows, processes])
return (
<>
<FilterSection>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '10px 14px' }}>
<div>
<label style={fLbl}>{t.filter.numberTitle}</label>
<input style={fInp} value={f.search} onChange={e => set('search', e.target.value)} placeholder={t.filter.searchPlaceholder} />
</div>
<div>
<label style={fLbl}>{t.filter.organization}</label>
<select style={fInp} value={f.organization} onChange={e => set('organization', e.target.value)}>
<option value="">{t.filter.allOrgs}</option>
{organizations.map(o => <option key={o.name} value={o.name}>{o.organization_name || o.name}</option>)}
</select>
</div>
<div>
<label style={fLbl}>{t.filter.department}</label>
<select style={fInp} value={f.department} onChange={e => set('department', e.target.value)}>
<option value="">{t.filter.allDepts}</option>
{departments.map(d => <option key={d.name} value={d.name}>{d.department_name || d.name}</option>)}
</select>
</div>
<div>
<label style={fLbl}>{t.filter.branch}</label>
<select style={fInp} value={f.branch} onChange={e => set('branch', e.target.value)}>
<option value="">{t.filter.allBranches}</option>
{branches.map(b => <option key={b.name} value={b.name}>{b.branch || b.name}</option>)}
</select>
</div>
<div>
<label style={fLbl}>{t.filter.status}</label>
<select style={fInp} value={f.status} onChange={e => set('status', e.target.value)}>
<option value="">{t.filter.allStatuses}</option>
{processStatuses.map(s => <option key={s.name} value={s.name}>{s.name1 || s.name}</option>)}
</select>
</div>
</div>
</FilterSection>
<div style={{ background: 'white', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.06)', overflow: 'hidden', border: '1px solid #f1f5f9' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f8fafc', color: '#64748b', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>
<th style={{ width: 44, padding: '12px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageRows.length > 0 && pageRows.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageRows.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageRows.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.number}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.title}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.organization}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.field.cycleType}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.status}</th>
<th style={{ padding: '12px 16px', fontWeight: 600, fontSize: 12 }}>{t.col.actions}</th>
</tr>
</thead>
<tbody>
{rows.length === 0
? <tr><td colSpan={7} style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>{t.noProcesses}</td></tr>
: pageRows.map((proc, i) => (
<tr
key={proc.name}
className="app-table-row"
onClick={() => onView?.(proc)}
style={{
borderTop: '1px solid #f1f5f9',
background: selectedIds.has(proc.name) ? '#eff6ff' : (proc.custom_is_deleted ? '#fff1f2' : (i % 2 === 0 ? 'white' : '#fafafa')),
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
>
<td style={{ width: 44, padding: '10px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(proc.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(proc.name) ? next.delete(proc.name) : next.add(proc.name)
setSelectedIds(next)
}}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '12px 16px', color: '#64748b', fontSize: 12, fontWeight: 500 }}>{proc.number || '—'}</td>
<td style={{ padding: '12px 16px', fontWeight: 600, color: '#1e293b' }}>{proc.title || proc.name || '—'}</td>
<td style={{ padding: '12px 16px', color: '#475569' }}>{proc.organization || '—'}</td>
<td style={{ padding: '12px 16px' }}>{proc.cycle_type ? typeBadge(proc.cycle_type) : '—'}</td>
<td style={{ padding: '12px 16px' }}>{proc.custom_is_deleted ? statusBadge(t.deletedBadge || 'DELETED') : (proc.status ? statusBadge(proc.status) : '—')}</td>
<td style={{ padding: '10px 16px' }} onClick={e => e.stopPropagation()}>
<RowActions
onView={() => onView?.(proc)}
onEdit={
canModify && onEdit && !proc.custom_is_deleted && !isWorkflowClosed('process', proc.workflow_state) && userRole !== 'Employee'
? () => onEdit(proc)
: undefined
}
onDelete={canModify && onDelete && !proc.custom_is_deleted ? () => onDelete(proc.name) : undefined}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={rows.length} onPageChange={setPage} />
</>
)
}

View File

@ -0,0 +1,353 @@
import { useState, useEffect } from 'react'
import { Organization } from '../types'
import { getReportData } from '../api'
import { colors } from '../constants'
import { useT } from '../i18n'
import DateInput from '../components/DateInput'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
interface ReportsPageProps {
organizations: Organization[]
userRole: string
}
interface ReportDef {
id: string
label: string
description: string
roles: string[]
extraFilter?: 'year'
}
const ALL_REPORTS: ReportDef[] = [
{
id: 'process_status_history',
label: 'Process Status History',
description: 'Full cycle-by-cycle status for any process, with actor, date, and escalation level',
roles: ['CEO', 'Chairman', 'Admin', 'Auditor', 'XAuditor', 'Manager', 'Owner'],
},
{
id: 'order_tracker',
label: 'Outsourcing Order Tracker',
description: 'Stage-by-stage progress for all orders, with cost variance',
roles: ['CEO', 'Chairman', 'Admin', 'Auditor', 'XAuditor', 'Manager', 'Owner'],
},
{
id: 'incident_register',
label: 'Incident Register',
description: 'All incidents with type, status, assigned manager, and resolution timeline',
roles: ['CEO', 'Chairman', 'Admin', 'Auditor', 'XAuditor', 'Manager', 'Owner'],
},
{
id: 'ideas_pipeline',
label: 'Ideas Pipeline',
description: 'All ideas by status and type, conversion rate to Study',
roles: ['CEO', 'Chairman', 'Admin', 'Auditor', 'XAuditor', 'Manager', 'Owner'],
},
{
id: 'department_performance',
label: 'Department Performance',
description: 'Aggregate Pass/Fail/Unknown per department per cycle',
roles: ['CEO', 'Chairman', 'Admin', 'Auditor', 'XAuditor', 'Manager', 'Owner'],
},
{
id: 'escalation_report',
label: 'Escalation Report',
description: 'All Level 2 escalations with entity type and current status',
roles: ['CEO', 'Chairman', 'Admin', 'Auditor', 'XAuditor', 'Manager', 'Owner'],
},
{
id: 'cost_summary',
label: 'Cost Summary',
description: 'Total cost per order, per department — budget vs actual',
roles: ['CEO', 'Chairman', 'Admin', 'Auditor', 'XAuditor', 'Manager', 'Owner'],
},
{
id: 'process_year_end',
label: 'Process Year-End Summary',
description: 'Full status history and accumulated cost per process for a given year (Rule 11.1-6)',
roles: ['CEO', 'Chairman', 'Admin', 'Auditor', 'XAuditor', 'Manager', 'Owner'],
extraFilter: 'year',
},
{
id: 'user_activity_log',
label: 'User Activity Log',
description: 'All actions by user with timestamp and entity reference',
roles: ['CEO', 'Chairman', 'Admin', 'Auditor', 'Owner'],
},
]
const COLUMN_LABELS: Record<string, string> = {
number: 'Number', title: 'Title', organization: 'Organization', department: 'Department',
status: 'Status', actor: 'Actor', cycle: 'Cycle', date_start: 'Start Date', date_end: 'End Date',
escalation_level: 'Escalation', stage: 'Stage', completion_pct: 'Completion %', budget: 'Budget',
starting_date: 'Start', ending_date: 'End', date: 'Date', assigned_to: 'Assigned To',
type: 'Type', entity_type: 'Entity', pass_count: 'Pass', fail_count: 'Fail',
unknown_count: 'Unknown', total: 'Total', cycle_type: 'Cycle Type', cost_per_cycle: 'Cost/Cycle',
total_cycles: 'Total Cycles', total_cost: 'Total Cost', timestamp: 'Timestamp',
user: 'User', entity_name: 'Entity', changes: 'Fields Changed',
}
function toCSV(columns: string[], rows: any[]): string {
const header = columns.map(c => COLUMN_LABELS[c] || c).join(',')
const body = rows.map(r => columns.map(c => `"${r[c] ?? ''}"`).join(',')).join('\n')
return header + '\n' + body
}
function downloadCSV(data: string, filename: string) {
const blob = new Blob([data], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = filename; a.click()
URL.revokeObjectURL(url)
}
// Excel export without a spreadsheet library: Excel opens an HTML table saved as
// .xls natively (SpreadsheetML-compatible), keeping column headers and cell types.
const escapeXml = (v: unknown) =>
String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
function downloadExcel(columns: string[], rows: any[], filename: string) {
const header = columns.map(c => `<th style="background:#4f46e5;color:#fff;padding:6px 10px;text-align:left">${escapeXml(COLUMN_LABELS[c] || c)}</th>`).join('')
const body = rows.map(r =>
`<tr>${columns.map(c => `<td style="padding:4px 10px;border-bottom:1px solid #ddd">${escapeXml(r[c])}</td>`).join('')}</tr>`
).join('')
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><head><meta charset="UTF-8"><!--[if gte mso 9]><xml><x:ExcelWorkbook><x:ExcelWorksheets><x:ExcelWorksheet><x:Name>Report</x:Name><x:WorksheetOptions/></x:ExcelWorksheet></x:ExcelWorksheets></x:ExcelWorkbook></xml><![endif]--></head><body><table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table></body></html>`
const blob = new Blob(['' + html], { type: 'application/vnd.ms-excel' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = filename; a.click()
URL.revokeObjectURL(url)
}
const PRINT_AREA_ID = 'report-print-area'
const PRINT_PORTAL_ID = 'report-print-portal'
// window.print() prints whatever is on screen. Hiding everything else with visibility:hidden
// still leaves their layout boxes in place (e.g. the sidebar's fixed 100vh height), which is
// exactly what was producing a blank second page. So instead of hiding siblings in place,
// clone just the results table into a fresh element outside the app's fixed-height layout
// entirely, hide the whole app (#root) during print, and print only that clone — it paginates
// to its own actual content height, with no leftover blank page.
function printReportArea() {
const source = document.getElementById(PRINT_AREA_ID)
if (!source) return
const portal = document.createElement('div')
portal.id = PRINT_PORTAL_ID
portal.innerHTML = source.innerHTML
document.body.appendChild(portal)
const style = document.createElement('style')
style.id = 'report-print-style'
style.textContent = `
#${PRINT_PORTAL_ID} { display: none; }
@media print {
#root { display: none !important; }
#${PRINT_PORTAL_ID} { display: block !important; }
}
`
document.head.appendChild(style)
const cleanup = () => {
portal.remove()
style.remove()
window.removeEventListener('afterprint', cleanup)
}
window.addEventListener('afterprint', cleanup)
window.print()
}
const sel: React.CSSProperties = {
padding: '8px 12px', borderRadius: 8, border: '1px solid #d1d5db', fontSize: 13,
background: 'white', minWidth: 180,
}
const inp: React.CSSProperties = {
padding: '8px 12px', borderRadius: 8, border: '1px solid #d1d5db', fontSize: 13, background: 'white',
}
export default function ReportsPage({ organizations, userRole }: ReportsPageProps) {
const t = useT()
const normalizedRole = (userRole || '').trim().toLowerCase()
const isPrivileged = !normalizedRole || ['admin', 'ceo', 'chairman', 'owner', 'system manager', 'administrator'].includes(normalizedRole)
const filtered = ALL_REPORTS.filter(r => {
if (isPrivileged) return true
return r.roles.some(role => role.toLowerCase() === normalizedRole)
})
const visibleReports = filtered.length > 0 ? filtered : ALL_REPORTS
const [reportId, setReportId] = useState('')
const [orgFilter, setOrgFilter] = useState('')
const [fromDate, setFromDate] = useState('')
const [toDate, setToDate] = useState('')
const [yearFilter, setYearFilter] = useState(String(new Date().getFullYear()))
const [columns, setColumns] = useState<string[]>([])
const [rows, setRows] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [page, setPage] = useState(1)
useEffect(() => { setPage(1) }, [reportId, orgFilter, yearFilter])
const pageRows = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
const selectedReport = visibleReports.find(r => r.id === reportId)
async function runReport() {
if (!reportId) return
setLoading(true); setError(''); setRows([]); setColumns([])
try {
const filters: Record<string, string> = {}
if (orgFilter) filters.organization = orgFilter
if (fromDate) filters.from_date = fromDate
if (toDate) filters.to_date = toDate
if (selectedReport?.extraFilter === 'year' && yearFilter) filters.year = yearFilter
const result = await getReportData(reportId, filters)
setColumns(result.columns || [])
setRows(result.rows || [])
} catch (e: any) {
setError(e.message || 'Error running report')
} finally {
setLoading(false)
}
}
const reportLabel = selectedReport?.label || reportId
return (
<div>
{/* Report catalogue cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 12, marginBottom: 20 }}>
{visibleReports.map(r => (
<div
key={r.id}
onClick={() => setReportId(r.id)}
style={{
background: reportId === r.id ? '#ebf5fb' : 'white',
border: reportId === r.id ? `2px solid ${colors.accent}` : '2px solid transparent',
borderRadius: 10, padding: '14px 16px', cursor: 'pointer',
boxShadow: '0 1px 4px rgba(0,0,0,0.07)',
}}
>
<div style={{ fontWeight: 700, fontSize: 14, color: '#1a2332', marginBottom: 4 }}>{r.label}</div>
<div style={{ fontSize: 12, color: '#888' }}>{r.description}</div>
</div>
))}
</div>
{/* Filter bar */}
<div style={{ background: 'white', borderRadius: 12, padding: 20, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', marginBottom: 20 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: '#999', marginBottom: 4 }}>REPORT</div>
<select style={sel} value={reportId} onChange={e => setReportId(e.target.value)}>
<option value="">-- Select Report --</option>
{visibleReports.map(r => <option key={r.id} value={r.id}>{r.label}</option>)}
</select>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: '#999', marginBottom: 4 }}>ORGANIZATION</div>
<select style={sel} value={orgFilter} onChange={e => setOrgFilter(e.target.value)}>
<option value="">{t.filter.allOrgs}</option>
{organizations.map(o => <option key={o.name} value={o.name}>{o.organization_name || o.name}</option>)}
</select>
</div>
{selectedReport?.extraFilter === 'year' ? (
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: '#999', marginBottom: 4 }}>YEAR</div>
<input type="number" style={{ ...inp, width: 90 }} value={yearFilter}
onChange={e => setYearFilter(e.target.value)} min={2020} max={2099} />
</div>
) : (
<>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: '#999', marginBottom: 4 }}>FROM</div>
<DateInput value={fromDate} onChange={setFromDate} style={inp} />
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: '#999', marginBottom: 4 }}>TO</div>
<DateInput value={toDate} onChange={setToDate} style={inp} />
</div>
</>
)}
<button
onClick={runReport}
disabled={!reportId || loading}
style={{ padding: '9px 20px', borderRadius: 8, border: 'none', background: colors.accent, color: 'white', fontWeight: 700, fontSize: 13, cursor: reportId ? 'pointer' : 'not-allowed', opacity: reportId ? 1 : 0.5 }}
>
{loading ? t.loading : t.reports.generate}
</button>
{rows.length > 0 && (
<button
onClick={() => downloadCSV(toCSV(columns, rows), `${reportId}.csv`)}
style={{ padding: '9px 20px', borderRadius: 8, border: '1px solid #27ae60', background: 'white', color: '#27ae60', fontWeight: 700, fontSize: 13, cursor: 'pointer' }}
>
Export CSV
</button>
)}
{rows.length > 0 && (
<button
onClick={() => downloadExcel(columns, rows, `${reportId}.xls`)}
style={{ padding: '9px 20px', borderRadius: 8, border: '1px solid #107c41', background: 'white', color: '#107c41', fontWeight: 700, fontSize: 13, cursor: 'pointer' }}
>
Export Excel
</button>
)}
{rows.length > 0 && (
<button
onClick={printReportArea}
style={{ padding: '9px 20px', borderRadius: 8, border: '1px solid #888', background: 'white', color: '#555', fontWeight: 700, fontSize: 13, cursor: 'pointer' }}
>
Print / PDF
</button>
)}
</div>
</div>
{error && <div style={{ color: '#e74c3c', marginBottom: 16 }}>{error}</div>}
{rows.length > 0 && (
<div id={PRINT_AREA_ID} style={{ background: 'white', borderRadius: 10, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', overflow: 'auto' }}>
<div style={{ padding: '14px 18px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 700, color: '#333' }}>{reportLabel}</span>
<span style={{ fontSize: 12, color: '#888' }}>{rows.length} rows</span>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8f9fa' }}>
{columns.map(c => (
<th key={c} style={{ padding: '10px 14px', textAlign: 'left', color: '#999', fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap' }}>
{COLUMN_LABELS[c] || c.replace(/_/g, ' ').toUpperCase()}
</th>
))}
</tr>
</thead>
<tbody>
{pageRows.map((row, i) => (
<tr key={i} style={{ borderTop: '1px solid #f0f0f0', background: i % 2 === 0 ? 'white' : '#fafafa' }}>
{columns.map(c => (
<td key={c} style={{ padding: '10px 14px', color: '#333' }}>
{row[c] != null ? String(row[c]) : '—'}
</td>
))}
</tr>
))}
</tbody>
</table>
<Pagination page={page} total={rows.length} onPageChange={setPage} />
</div>
)}
{!loading && rows.length === 0 && reportId && !error && (
<div style={{ background: 'white', borderRadius: 12, padding: 40, textAlign: 'center', color: '#aaa', boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
Select a report and click Run Report to see results.
</div>
)}
{visibleReports.length === 0 && (
<div style={{ background: 'white', borderRadius: 12, padding: 40, textAlign: 'center', color: '#aaa', boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
No reports are available for your role.
</div>
)}
</div>
)
}

View File

@ -0,0 +1,335 @@
import { useT } from '../i18n'
import { Dispatch, SetStateAction } from 'react'
import { SBUser, Organization, Management, Department, Branch, User, Page, SBGroupCompany } from '../types'
import { inp, lbl, activeItems, isSoftDeleted, FormCard, DeletedBanner } from '../components/common'
import FormPageLayout from '../components/FormPageLayout'
import OrgSelect from '../components/OrgSelect'
import LinkSelect from '../components/LinkSelect'
import { DOCTYPES } from '../constants'
interface Props {
form: SBUser
setForm: Dispatch<SetStateAction<SBUser>>
groupCompanies?: SBGroupCompany[]
organizations: Organization[]
managements: Management[]
departments: Department[]
branches: Branch[]
users?: any[]
formStatus: string
viewMode?: boolean
onEdit?: () => void
onRestore?: (name: string) => void
onPermanentDelete?: (name: string) => void
darkMode: boolean
notifCount: number
onToggleDark: () => void
onCountChange: (n: number) => void
onViewProfile: () => void
isSelfProfile?: boolean
onSubmit: () => void
onBack: () => void
onNavigate: (page: Page) => void
onLogout: () => void
user: User
}
export default function UserFormPage({
form, setForm, groupCompanies = [], organizations, managements, departments, branches, users = [],
formStatus, viewMode, onEdit, onRestore, onPermanentDelete, darkMode, notifCount, onToggleDark, onCountChange, onViewProfile,
isSelfProfile, onSubmit, onBack, onNavigate, onLogout, user
}: Props) {
const t = useT()
const set = (patch: Partial<SBUser>) => setForm(f => ({ ...f, ...patch }))
const di: React.CSSProperties = viewMode ? { background: '#f8fafc' } : {}
const gcItems = groupCompanies.filter(g => !isSoftDeleted(g)).map(g => ({ ...g, name: g.name, label: g.company_name || g.name }))
// Filter managements, departments, branches by selected organization
const filteredManagements = form.custom_organization
? managements.filter(m => !isSoftDeleted(m) && (!m.organization || m.organization === form.custom_organization))
: managements.filter(m => !isSoftDeleted(m))
const filteredDepartments = departments.filter(d => {
if (isSoftDeleted(d)) return false
if (form.custom_organization && d.custom_organization && d.custom_organization !== form.custom_organization) return false
if (form.custom_management && d.custom_management && d.custom_management !== form.custom_management) return false
return true
})
const filteredBranches = branches.filter(b => {
if (isSoftDeleted(b)) return false
if (form.custom_organization && b.organization && b.organization !== form.custom_organization) return false
if (form.custom_management && b.management && b.management !== form.custom_management) return false
if (form.custom_department && b.department && b.department !== form.custom_department) return false
return true
})
const mgmtItems = activeItems(filteredManagements, form.custom_management).map(m => ({ ...m, name: m.name, label: m.name1 || m.name }))
const deptItems = activeItems(filteredDepartments, form.custom_department).map(d => ({ ...d, name: d.name, label: d.department_name || d.name }))
const branchItems = activeItems(filteredBranches, form.custom_branch).map(b => ({ ...b, name: b.name, label: b.branch || b.name }))
const supervisorOptions = users.filter(u => {
if (!u || isSoftDeleted(u) || u.enabled === 0 || u.enabled === false) return false
if (u.name === form.name) return false
const candCat = u.custom_category || ''
const isGlobal = candCat === 'CEO' || candCat === 'Chairman' || candCat === 'Business Owner' || u.name === 'Administrator'
// If an organization is chosen, supervisor must belong to that organization (unless global CEO)
if (form.custom_organization && u.custom_organization && u.custom_organization !== form.custom_organization && !isGlobal) {
return false
}
// Role hierarchy filtering
if (form.custom_category === 'Admin') {
// Admin reports to CEO / Administrator / Business Owner
return candCat === 'CEO' || candCat === 'Chairman' || candCat === 'Business Owner' || u.name === 'Administrator'
} else if (form.custom_category === 'Manager') {
// Manager reports to Admin
return candCat === 'Admin'
} else if (form.custom_category === 'Auditor' || form.custom_category === 'X-Auditor') {
// Auditor reports to Manager (or Admin if no manager exists in org)
return candCat === 'Manager' || candCat === 'Admin' || candCat === 'Business Owner'
} else if (form.custom_category === 'Employee') {
// Employee reports to Auditor (or Manager if no auditor exists in org)
return candCat === 'Auditor' || candCat === 'X-Auditor' || candCat === 'Manager'
} else if (form.custom_category === 'CEO' || form.custom_category === 'Chairman' || form.custom_category === 'Business Owner') {
return false
}
return true
})
const isNew = !form.name || form.name.startsWith('new-user-')
const title = isSelfProfile ? t.formTitle.viewUser : (isNew ? t.formTitle.newUser : t.formTitle.editUser)
const handleSubmit = (e?: React.FormEvent) => {
if (e) e.preventDefault()
if (!form.first_name) {
alert(t.field.fullName + ' is required')
return
}
if (isNew && !form.email) {
alert(t.field.emailAddress + ' is required')
return
}
const effectiveGroupCompany = form.custom_group_company || user.custom_group_company
if (!effectiveGroupCompany) {
alert('Group Company (Tenant) is required')
return
}
if (!form.custom_organization) {
alert((t.field.organization || 'Organization') + ' is required')
return
}
if (!form.custom_category) {
alert((t.field.category || 'Category') + ' is required')
return
}
if (form.custom_category && form.custom_category !== 'CEO' && form.custom_category !== 'Chairman' && form.custom_category !== 'Business Owner' && !form.custom_manager) {
alert('Reports To / Supervisor is mandatory.')
return
}
if (!form.custom_group_company && effectiveGroupCompany) {
set({ custom_group_company: effectiveGroupCompany })
}
onSubmit()
}
return (
<FormPageLayout
title={title} activePage="users"
user={user} formStatus={formStatus}
buttonText={t.saveUser} buttonColor="#16a34a"
viewMode={viewMode} onEdit={onEdit} darkMode={darkMode} notifCount={notifCount} onToggleDark={onToggleDark} onCountChange={onCountChange} onViewProfile={onViewProfile}
onSubmit={handleSubmit} onBack={onBack} onNavigate={onNavigate} onLogout={onLogout}
>
{(form as any).custom_is_deleted === 1 && (
<DeletedBanner
deletedBy={(form as any).custom_deleted_by}
deletedOn={(form as any).custom_deleted_on}
reason={(form as any).custom_deleted_reason}
onRestore={onRestore && form.name ? () => onRestore(form.name) : undefined}
onPermanentDelete={onPermanentDelete && form.name ? () => onPermanentDelete(form.name) : undefined}
/>
)}
<FormCard title="User Organization & Assignment">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
{(user.role === 'Admin' || user.role === 'CEO') && !user.custom_group_company && (
<div>
<label style={lbl}>Group Company (Tenant) <span style={{ color: '#e74c3c' }}>*</span></label>
<LinkSelect
value={form.custom_group_company || ''}
onChange={v => set({ custom_group_company: v })}
items={gcItems}
placeholder="-- Select Group Company --"
disabled={viewMode}
userRole={user.role}
doctypeName={DOCTYPES.groupCompanies}
/>
</div>
)}
<div>
<label style={lbl}>{t.field.organization} <span style={{ color: '#e74c3c' }}>*</span></label>
<OrgSelect
value={form.custom_organization}
onChange={v => set({
custom_organization: v,
custom_management: '',
custom_department: '',
custom_branch: '',
custom_manager: '',
})}
organizations={organizations}
disabled={viewMode}
userRole={user.role}
/>
</div>
<div>
<label style={lbl}>{t.field.management}</label>
<LinkSelect
value={form.custom_management}
onChange={v => set({ custom_management: v, custom_department: '', custom_branch: '' })}
items={mgmtItems}
placeholder="-- Select --"
disabled={viewMode}
userRole={user.role}
doctypeName={DOCTYPES.managements}
extraFields={{ organization: form.custom_organization }}
/>
</div>
<div>
<label style={lbl}>{t.field.department}</label>
<LinkSelect
value={form.custom_department}
onChange={v => set({ custom_department: v, custom_branch: '' })}
items={deptItems}
placeholder="-- Select --"
disabled={viewMode}
userRole={user.role}
doctypeName={DOCTYPES.departments}
extraFields={{ organization: form.custom_organization, management: form.custom_management }}
/>
</div>
<div>
<label style={lbl}>{t.field.branch}</label>
<LinkSelect
value={form.custom_branch}
onChange={v => set({ custom_branch: v })}
items={branchItems}
placeholder="-- Select --"
disabled={viewMode}
userRole={user.role}
doctypeName={DOCTYPES.branches}
extraFields={{ organization: form.custom_organization, management: form.custom_management, department: form.custom_department }}
/>
</div>
</div>
</FormCard>
<FormCard title="User Profile Details">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
<div>
<label style={lbl}>{t.field.number}</label>
<input style={{ ...inp, ...di }} value={form.custom_number} disabled={viewMode} onChange={e => set({ custom_number: e.target.value })} placeholder="e.g. 10001" />
</div>
<div>
<label style={lbl}>{t.field.fullName} <span style={{ color: '#e74c3c' }}>*</span></label>
<input style={{ ...inp, ...di }} value={form.first_name} disabled={viewMode} onChange={e => set({ first_name: e.target.value })} placeholder="Full name" required />
</div>
<div>
<label style={lbl}>{t.field.username}</label>
<input style={{ ...inp, ...di }} value={form.username} disabled={viewMode} onChange={e => set({ username: e.target.value })} placeholder="Username" />
</div>
<div>
<label style={lbl}>{t.field.phoneNumber}</label>
<input style={{ ...inp, ...di }} value={form.mobile_no} disabled={viewMode} onChange={e => set({ mobile_no: e.target.value })} placeholder="Phone number" />
</div>
<div>
<label style={lbl}>{t.field.emailAddress} {isNew && <span style={{ color: '#e74c3c' }}>*</span>}</label>
<input
style={{ ...inp, ...(viewMode || !isNew ? { background: '#f8fafc' } : {}) }}
value={form.email}
disabled={viewMode || !isNew}
onChange={e => set({ email: e.target.value })}
placeholder="user@example.com"
type="email"
required={isNew}
/>
</div>
{isNew && (
<div>
<label style={lbl}>Password <span style={{ color: '#e74c3c' }}>*</span></label>
<input
style={{ ...inp, ...di }}
type="password"
value={form.new_password || ''}
disabled={viewMode}
onChange={e => set({ new_password: e.target.value })}
placeholder="Initial password"
required
/>
</div>
)}
<div>
<label style={lbl}>{t.field.category} <span style={{ color: '#e74c3c' }}>*</span></label>
<select
style={{ ...inp, ...di }}
value={form.custom_category}
disabled={viewMode}
onChange={e => {
const newCat = e.target.value
set({ custom_category: newCat, custom_manager: '' })
}}
>
<option value="">{t.field.select}</option>
{(() => {
const isCreatorAdmin = user.role === 'Admin'
const isCreatorBizOwner = user.role === 'Business Owner' || user.role === 'Owner'
const availableCategories = isCreatorBizOwner
? ['CEO', 'Auditor', 'X-Auditor']
: isCreatorAdmin
? ['Admin', 'Auditor', 'X-Auditor', 'Manager', 'Employee']
: ['Admin', 'Business Owner', 'CEO', 'Chairman', 'Auditor', 'X-Auditor', 'Manager', 'Employee']
const categoryOptions = Array.from(new Set([...availableCategories, form.custom_category])).filter(Boolean)
return categoryOptions.map(c => (
<option key={c} value={c}>{c}</option>
))
})()}
</select>
</div>
{form.custom_category !== 'CEO' && form.custom_category !== 'Chairman' && form.custom_category !== 'Business Owner' && (
<div>
<label style={lbl}>
Reports To / Supervisor{' '}
{form.custom_category && <span style={{ color: '#e74c3c' }}>*</span>}
</label>
<select
style={{
...inp,
...di,
...(!form.custom_category ? { background: '#f1f5f9', cursor: 'not-allowed', color: '#94a3b8' } : {}),
}}
value={form.custom_manager || ''}
disabled={viewMode || !form.custom_category}
onChange={e => set({ custom_manager: e.target.value })}
required={!!form.custom_category}
>
<option value=""></option>
{supervisorOptions.map(m => {
const label = m.full_name || m.email || m.name
const cat = m.custom_category ? ` [${m.custom_category}]` : ''
return (
<option key={m.name} value={m.name}>
{label}{cat}
</option>
)
})}
</select>
</div>
)}
</div>
</FormCard>
</FormPageLayout>
)
}

View File

@ -0,0 +1,473 @@
import { useState } from 'react'
import { User, Page } from '../types'
import { updateDoc, setUserPassword } from '../api'
import { activeItems, isSoftDeleted } from '../components/common'
interface Props {
user: User
profileUser: any // the specific SB user being viewed
sbUsers: any[]
organizations?: any[]
managements?: any[]
departments?: any[]
branches?: any[]
groupCompanies?: any[]
darkMode: boolean
canModify?: boolean
onBack: () => void
onNavigate: (page: Page) => void
}
const ROLE_COLOR: Record<string, string> = {
CEO: '#8b5cf6', Admin: '#7c3aed', Auditor: '#d97706', Owner: '#0369a1',
'Business Owner': '#0369a1',
XAuditor: '#0f766e', Manager: '#2563eb', Employee: '#6b7280',
Chairman: '#0f766e',
}
export default function UserProfilePage({
user, profileUser, darkMode, canModify, onBack, sbUsers = [],
organizations = [], managements = [], departments = [], branches = [], groupCompanies = [],
}: Props) {
const u = profileUser || {}
const canEditOrgAndCategory = !!canModify && (user.role === 'Admin' || user.role === 'CEO' || user.role === 'Business Owner' || user.role === 'Owner')
const [form, setForm] = useState({
first_name: u.first_name || '',
middle_name: u.middle_name || '',
last_name: u.last_name || '',
mobile_no: u.mobile_no || '',
custom_organization: u.custom_organization || '',
custom_group_company: u.custom_group_company || '',
custom_manager: u.custom_manager || '',
custom_management: u.custom_management || '',
custom_department: u.custom_department || '',
custom_branch: u.custom_branch || '',
custom_number: u.custom_number || '',
custom_category: u.custom_category || '',
})
const [saveStatus, setSaveStatus] = useState('')
const [pwOpen, setPwOpen] = useState(false)
const [pw, setPw] = useState({ new: '', confirm: '' })
const [pwStatus, setPwStatus] = useState('')
const email = u.email || u.name || ''
const fullName = [form.first_name, form.middle_name, form.last_name].filter(Boolean).join(' ') || u.full_name || ''
const username = u.username || u.name || ''
const category = form.custom_category || ''
const status = u.enabled === 0 ? 'Inactive' : 'Active'
const roleColor = ROLE_COLOR[user.role] || '#6b7280'
const dm = darkMode
const bg = dm ? '#0f172a' : '#f0f2f5'
const surface = dm ? '#1e293b' : 'white'
const border = dm ? '#2d3748' : '#e5e7eb'
const textMain = dm ? '#e2e8f0' : '#1a2332'
const textMuted = dm ? '#94a3b8' : '#6b7280'
const inputBg = dm ? '#374151' : 'white'
const inputROBg = dm ? '#2d3748' : '#f9fafb'
const inp: React.CSSProperties = { width: '100%', padding: '9px 12px', borderRadius: 8, border: `1px solid ${border}`, fontSize: 13, background: inputBg, color: textMain, fontFamily: 'inherit', outline: 'none' }
const inpRO: React.CSSProperties = { ...inp, background: inputROBg, color: textMuted }
const sel: React.CSSProperties = { ...inp, cursor: 'pointer' }
const lbl: React.CSSProperties = { fontSize: 12, fontWeight: 600, color: textMuted, marginBottom: 5, display: 'block' }
const r2: React.CSSProperties = { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }
const r3: React.CSSProperties = { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 14 }
const supervisorUser = (sbUsers || []).find((s: any) => s.name === form.custom_manager)
const supervisorDisplayName = supervisorUser ? (supervisorUser.full_name || supervisorUser.email || supervisorUser.name) : form.custom_manager
const profileSupervisorOptions = (sbUsers || []).filter((s: any) => {
if (!s || isSoftDeleted(s) || s.enabled === 0 || s.enabled === false) return false
if (s.name === u.name) return false
const candCat = s.custom_category || ''
const isGlobal = candCat === 'CEO' || candCat === 'Chairman' || candCat === 'Business Owner' || s.name === 'Administrator'
if (form.custom_organization && s.custom_organization && s.custom_organization !== form.custom_organization && !isGlobal) {
return false
}
if (form.custom_category === 'Admin') {
return candCat === 'CEO' || candCat === 'Chairman' || candCat === 'Business Owner' || s.name === 'Administrator'
} else if (form.custom_category === 'Manager') {
return candCat === 'Admin'
} else if (form.custom_category === 'Auditor' || form.custom_category === 'X-Auditor') {
return candCat === 'Manager' || candCat === 'Admin' || candCat === 'Business Owner'
} else if (form.custom_category === 'Employee') {
return candCat === 'Auditor' || candCat === 'X-Auditor' || candCat === 'Manager'
} else if (form.custom_category === 'CEO' || form.custom_category === 'Chairman' || form.custom_category === 'Business Owner') {
return false
}
return true
})
const filteredManagements = form.custom_organization
? managements.filter((m: any) => !isSoftDeleted(m) && (!m.organization || m.organization === form.custom_organization))
: managements.filter((m: any) => !isSoftDeleted(m))
const filteredDepartments = departments.filter((d: any) => {
if (isSoftDeleted(d)) return false
if (form.custom_organization && d.custom_organization && d.custom_organization !== form.custom_organization) return false
if (form.custom_management && d.custom_management && d.custom_management !== form.custom_management) return false
return true
})
const filteredBranches = branches.filter((b: any) => {
if (isSoftDeleted(b)) return false
if (form.custom_organization && b.organization && b.organization !== form.custom_organization) return false
if (form.custom_management && b.management && b.management !== form.custom_management) return false
if (form.custom_department && b.department && b.department !== form.custom_department) return false
return true
})
const handleSave = async () => {
if (!canModify) return
if (canEditOrgAndCategory) {
if (!form.custom_group_company) {
alert('Group Company (Tenant) is required')
return
}
if (!form.custom_organization) {
alert('Organization is required')
return
}
if (!form.custom_category) {
alert('Category is required')
return
}
if (form.custom_category !== 'CEO' && form.custom_category !== 'Chairman' && form.custom_category !== 'Business Owner' && !form.custom_manager) {
alert('Reports To / Supervisor is mandatory.')
return
}
}
setSaveStatus('saving')
try {
await updateDoc('User', u.name, {
first_name: form.first_name,
middle_name: form.middle_name,
last_name: form.last_name,
mobile_no: form.mobile_no,
custom_organization: form.custom_organization,
custom_group_company: form.custom_group_company,
custom_manager: form.custom_manager,
custom_management: form.custom_management,
custom_department: form.custom_department,
custom_branch: form.custom_branch,
custom_number: form.custom_number,
custom_category: form.custom_category,
})
setSaveStatus('saved')
setTimeout(() => setSaveStatus(''), 2000)
} catch (e: any) {
setSaveStatus('error')
setTimeout(() => setSaveStatus(''), 3000)
}
}
const handleChangePassword = async () => {
if (!pw.new) { setPwStatus('Enter a new password'); return }
if (pw.new !== pw.confirm) { setPwStatus('Passwords do not match'); return }
setPwStatus('saving')
try {
await setUserPassword(u.name, pw.new)
setPwStatus('done')
setPw({ new: '', confirm: '' })
setTimeout(() => { setPwStatus(''); setPwOpen(false) }, 2000)
} catch (e: any) {
setPwStatus('error')
}
}
const Card = ({ title, icon, children }: { title: string; icon: React.ReactNode; children: React.ReactNode }) => (
<div style={{ background: surface, borderRadius: 12, padding: 22, border: `1px solid ${border}`, boxShadow: `0 1px 4px rgba(0,0,0,${dm ? 0.3 : 0.06})` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 18, paddingBottom: 14, borderBottom: `1px solid ${border}` }}>
{icon}
<span style={{ fontSize: 14, fontWeight: 700, color: textMain }}>{title}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>{children}</div>
</div>
)
return (
<div style={{ background: bg, minHeight: '100%', padding: '0 0 32px' }}>
{/* Header row */}
<div style={{ background: surface, borderBottom: `1px solid ${border}`, padding: '12px 28px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}>
<button onClick={onBack} style={{ display: 'flex', alignItems: 'center', gap: 7, background: 'none', border: 'none', cursor: 'pointer', fontSize: 15, fontWeight: 700, color: textMain, fontFamily: 'inherit' }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><polyline points="15,18 9,12 15,6"/></svg>
User Profile
</button>
{canModify && (
<button onClick={handleSave} style={{ display: 'flex', alignItems: 'center', gap: 8, background: saveStatus === 'saved' ? '#16a34a' : saveStatus === 'error' ? '#dc2626' : '#16a34a', color: 'white', border: 'none', borderRadius: 8, padding: '9px 22px', fontSize: 13, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17,21 17,13 7,13 7,21"/><polyline points="7,3 7,8 15,8"/></svg>
{saveStatus === 'saving' ? 'Saving…' : saveStatus === 'saved' ? 'Saved!' : saveStatus === 'error' ? 'Error' : 'Save Changes'}
</button>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '280px 1fr', gap: 22, padding: '0 28px' }}>
{/* ── Left card ── */}
<div style={{ borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.12)', alignSelf: 'start' }}>
<div style={{ background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)', padding: '32px 20px 28px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
<div style={{ width: 78, height: 78, borderRadius: '50%', background: 'rgba(255,255,255,0.22)', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 4, border: '3px solid rgba(255,255,255,0.3)' }}>
<svg width="40" height="40" viewBox="0 0 24 24" fill="rgba(255,255,255,0.9)">
<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
</svg>
</div>
<div style={{ fontSize: 17, fontWeight: 700, color: 'white', textAlign: 'center', lineHeight: 1.3 }}>{fullName || email}</div>
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.75)', textAlign: 'center' }}>{email}</div>
</div>
<div style={{ background: surface }}>
{[
{ icon: <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke={roleColor} strokeWidth="2" strokeLinecap="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>, label: 'Username', value: username },
{ icon: <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke={roleColor} strokeWidth="2" strokeLinecap="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>, label: 'Role Profile', value: category || '—' },
{ icon: <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke={roleColor} strokeWidth="2" strokeLinecap="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>, label: 'Reports To / Supervisor', value: supervisorDisplayName || form.custom_manager || '—' },
{ icon: <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke={roleColor} strokeWidth="2" strokeLinecap="round"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>, label: 'Group Company', value: form.custom_group_company || '—' },
{ icon: <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke={status === 'Active' ? '#16a34a' : '#dc2626'} strokeWidth="2" strokeLinecap="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>, label: 'Status', value: status, valueColor: status === 'Active' ? '#16a34a' : '#dc2626' },
{ icon: <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke={roleColor} strokeWidth="2" strokeLinecap="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m2 7 10 7 10-7"/></svg>, label: 'Email', value: email },
{ icon: <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke={roleColor} strokeWidth="2" strokeLinecap="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 13a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>, label: 'Phone', value: form.mobile_no || '—' },
].map(({ icon, label, value, valueColor }) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 16px', borderBottom: `1px solid ${border}` }}>
<div style={{ flexShrink: 0, width: 18, display: 'flex', justifyContent: 'center' }}>{icon}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, color: textMuted, marginBottom: 1 }}>{label}</div>
<div style={{ fontSize: 13, fontWeight: 600, color: valueColor || textMain, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{value}</div>
</div>
</div>
))}
</div>
</div>
{/* ── Right panels ── */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
{/* Basic Information */}
<Card
title="Basic Information"
icon={<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="2" strokeLinecap="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>}
>
<div style={r2}>
<div>
<label style={lbl}>Email <span style={{ fontSize: 10, fontWeight: 400 }}>(Read-only)</span></label>
<div style={{ position: 'relative' }}>
<div style={{ position: 'absolute', left: 11, top: '50%', transform: 'translateY(-50%)' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="2"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m2 7 10 7 10-7"/></svg>
</div>
<input style={{ ...inpRO, paddingLeft: 32 }} value={email} readOnly />
</div>
</div>
<div>
<label style={lbl}>Full Name <span style={{ fontSize: 10, fontWeight: 400 }}>(Auto-generated)</span></label>
<input style={inpRO} value={fullName} readOnly />
</div>
</div>
<div style={r3}>
<div>
<label style={lbl}>First Name <span style={{ color: '#ef4444' }}>*</span></label>
<input style={canModify ? inp : inpRO} value={form.first_name} readOnly={!canModify} onChange={e => setForm(f => ({ ...f, first_name: e.target.value }))} placeholder="First name" />
</div>
<div>
<label style={lbl}>Middle Name</label>
<input style={canModify ? inp : inpRO} value={form.middle_name} readOnly={!canModify} onChange={e => setForm(f => ({ ...f, middle_name: e.target.value }))} placeholder="Middle name" />
</div>
<div>
<label style={lbl}>Last Name</label>
<input style={canModify ? inp : inpRO} value={form.last_name} readOnly={!canModify} onChange={e => setForm(f => ({ ...f, last_name: e.target.value }))} placeholder="Last name" />
</div>
</div>
<div style={r2}>
<div>
<label style={lbl}>Phone Number</label>
<input style={canModify ? inp : inpRO} value={form.mobile_no} readOnly={!canModify} onChange={e => setForm(f => ({ ...f, mobile_no: e.target.value }))} placeholder="Phone number" />
</div>
<div>
<label style={lbl}>User ID No</label>
<div style={{ position: 'relative' }}>
<input style={{ ...inpRO, paddingRight: 44 }} value={form.custom_number || u.name || ''} readOnly />
<div style={{ position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)', background: '#ef4444', borderRadius: 5, padding: '3px 7px', cursor: 'default', display: 'flex', alignItems: 'center' }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="white"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</div>
</div>
</div>
</div>
<div style={r2}>
<div>
<label style={lbl}>Category <span style={{ color: '#ef4444' }}>*</span> {!canEditOrgAndCategory && <span style={{ fontSize: 10, fontWeight: 400 }}>(Admin only)</span>}</label>
{canEditOrgAndCategory ? (
<select style={sel} value={form.custom_category} onChange={e => setForm(f => ({ ...f, custom_category: e.target.value }))}>
<option value="">Select category</option>
{(() => {
const isCreatorAdmin = user.role === 'Admin'
const isCreatorBizOwner = user.role === 'Business Owner' || user.role === 'Owner'
const availableCategories = isCreatorBizOwner
? ['CEO', 'Auditor', 'X-Auditor']
: isCreatorAdmin
? ['Admin', 'Auditor', 'X-Auditor', 'Manager', 'Employee']
: ['Admin', 'Business Owner', 'CEO', 'Chairman', 'Auditor', 'X-Auditor', 'Manager', 'Employee']
const catOptions = Array.from(new Set([...availableCategories, form.custom_category])).filter(Boolean)
return catOptions.map(c => <option key={c} value={c}>{c}</option>)
})()}
</select>
) : (
<input style={inpRO} value={category || '—'} readOnly />
)}
</div>
<div>
<label style={lbl}>Group Company (Tenant) <span style={{ color: '#ef4444' }}>*</span> {!canEditOrgAndCategory && <span style={{ fontSize: 10, fontWeight: 400 }}>(Admin only)</span>}</label>
{canEditOrgAndCategory ? (
<select style={sel} value={form.custom_group_company} onChange={e => setForm(f => ({ ...f, custom_group_company: e.target.value }))}>
<option value="">Select group company</option>
{groupCompanies.filter((g: any) => !isSoftDeleted(g)).map((g: any) => <option key={g.name} value={g.name}>{g.company_name || g.name}</option>)}
</select>
) : (
<input style={inpRO} value={form.custom_group_company || '—'} readOnly />
)}
</div>
</div>
{form.custom_category !== 'CEO' && form.custom_category !== 'Chairman' && form.custom_category !== 'Business Owner' && (
<div style={{ marginTop: 14 }}>
<label style={lbl}>Reports To / Supervisor {form.custom_category && <span style={{ color: '#ef4444' }}>*</span>} {!canEditOrgAndCategory && <span style={{ fontSize: 10, fontWeight: 400 }}>(Admin only)</span>}</label>
{canEditOrgAndCategory ? (
<select
style={sel}
value={form.custom_manager}
disabled={!form.custom_category}
onChange={e => setForm(f => ({ ...f, custom_manager: e.target.value }))}
>
<option value=""></option>
{profileSupervisorOptions.map((s: any) => {
const label = s.full_name || s.email || s.name
const cat = s.custom_category ? ` [${s.custom_category}]` : ''
return <option key={s.name} value={s.name}>{label}{cat}</option>
})}
</select>
) : (
<input style={inpRO} value={supervisorDisplayName || form.custom_manager || '—'} readOnly />
)}
</div>
)}
</Card>
{/* Organization Info */}
<Card
title="Organization Information"
icon={<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" strokeWidth="2" strokeLinecap="round"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>}
>
<div style={r2}>
<div>
<label style={lbl}>Organization <span style={{ color: '#ef4444' }}>*</span> {!canEditOrgAndCategory && <span style={{ fontSize: 10, fontWeight: 400 }}>(Admin only)</span>}</label>
{canEditOrgAndCategory ? (
<select
style={sel}
value={form.custom_organization}
onChange={e => setForm(f => ({
...f,
custom_organization: e.target.value,
custom_management: '',
custom_department: '',
custom_branch: '',
custom_manager: '',
}))}
>
<option value="">Select organization</option>
{activeItems(organizations, form.custom_organization).map((o: any) => <option key={o.name} value={o.name}>{o.organization_name || o.name}</option>)}
</select>
) : <input style={inpRO} value={form.custom_organization || '—'} readOnly />}
</div>
<div>
<label style={lbl}>Management {!canEditOrgAndCategory && <span style={{ fontSize: 10, fontWeight: 400 }}>(Admin only)</span>}</label>
{canEditOrgAndCategory ? (
<select
style={sel}
value={form.custom_management}
onChange={e => setForm(f => ({
...f,
custom_management: e.target.value,
custom_department: '',
custom_branch: '',
}))}
>
<option value="">Select management</option>
{activeItems(filteredManagements, form.custom_management).map((m: any) => <option key={m.name} value={m.name}>{m.name1 || m.name}</option>)}
</select>
) : <input style={inpRO} value={form.custom_management || '—'} readOnly />}
</div>
</div>
<div style={r2}>
<div>
<label style={lbl}>Department {!canEditOrgAndCategory && <span style={{ fontSize: 10, fontWeight: 400 }}>(Admin only)</span>}</label>
{canEditOrgAndCategory ? (
<select
style={sel}
value={form.custom_department}
onChange={e => setForm(f => ({
...f,
custom_department: e.target.value,
custom_branch: '',
}))}
>
<option value="">Select department</option>
{activeItems(filteredDepartments, form.custom_department).map((d: any) => <option key={d.name} value={d.name}>{d.department_name || d.name}</option>)}
</select>
) : <input style={inpRO} value={form.custom_department || '—'} readOnly />}
</div>
<div>
<label style={lbl}>Branch {!canEditOrgAndCategory && <span style={{ fontSize: 10, fontWeight: 400 }}>(Admin only)</span>}</label>
{canEditOrgAndCategory ? (
<select style={sel} value={form.custom_branch} onChange={e => setForm(f => ({ ...f, custom_branch: e.target.value }))}>
<option value="">Select branch</option>
{activeItems(filteredBranches, form.custom_branch).map((b: any) => <option key={b.name} value={b.name}>{b.branch || b.name}</option>)}
</select>
) : <input style={inpRO} value={form.custom_branch || '—'} readOnly />}
</div>
</div>
</Card>
{/* Change Password */}
<div style={{ background: surface, borderRadius: 12, padding: 22, border: `1px solid ${border}`, boxShadow: `0 1px 4px rgba(0,0,0,${dm ? 0.3 : 0.06})` }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round">
<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<span style={{ fontSize: 14, fontWeight: 700, color: textMain }}>Change Password</span>
</div>
{canModify && (
<button onClick={() => { setPwOpen(o => !o); setPwStatus('') }} style={{ fontSize: 13, color: '#3b82f6', background: 'none', border: 'none', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit' }}>
{pwOpen ? 'Cancel' : 'Change Password'}
</button>
)}
</div>
{pwOpen && (
<div style={{ marginTop: 18, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={r3}>
<div>
<label style={lbl}>New Password <span style={{ color: '#ef4444' }}>*</span></label>
<input type="password" style={inp} value={pw.new} onChange={e => setPw(p => ({ ...p, new: e.target.value }))} placeholder="New password" />
</div>
<div>
<label style={lbl}>Confirm Password <span style={{ color: '#ef4444' }}>*</span></label>
<input type="password" style={inp} value={pw.confirm} onChange={e => setPw(p => ({ ...p, confirm: e.target.value }))} placeholder="Confirm password" />
</div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<button onClick={handleChangePassword} style={{ width: '100%', padding: '9px 0', borderRadius: 8, border: 'none', background: '#2563eb', color: 'white', fontWeight: 700, fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>
{pwStatus === 'saving' ? 'Saving…' : 'Update Password'}
</button>
</div>
</div>
{pwStatus && pwStatus !== 'saving' && (
<div style={{ fontSize: 13, color: pwStatus === 'done' ? '#16a34a' : '#dc2626', fontWeight: 500 }}>
{pwStatus === 'done' ? 'Password updated successfully' : pwStatus === 'error' ? 'Failed to update password' : pwStatus}
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,263 @@
import { useEffect, useState } from 'react'
import { setUserPassword } from '../api'
import { useT } from '../i18n'
import Pagination, { PAGE_SIZE } from '../components/Pagination'
interface Props {
users: any[]
roleMap: Record<string, string[]>
canModify?: boolean
onView?: (u: any) => void
onEdit?: (u: any) => void
onDelete?: (name: string) => void
onDisable?: (name: string) => void
onSelectionChange?: (selected: any[], page: any[]) => void
}
const ROLE_COLOR: Record<string, string> = {
Admin: '#7c3aed', Chairman: '#0f766e', Auditor: '#d97706',
'X-Auditor': '#8b5cf6', Manager: '#2563eb', Employee: '#6b7280',
}
function formatDate(d: string): string {
if (!d) return '—'
const dt = new Date(d)
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')}/${dt.getFullYear()}`
}
export default function UsersPage({ users, canModify, onView, onEdit, onDisable, onSelectionChange }: Props) {
const t = useT()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [filtersOpen, setFiltersOpen] = useState(false)
const [search, setSearch] = useState('')
const [orgFilter, setOrgFilter] = useState('')
const [catFilter, setCatFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('active')
const [page, setPage] = useState(1)
const [pwdModal, setPwdModal] = useState<{ name: string; email: string } | null>(null)
const [newPwd, setNewPwd] = useState('')
const [pwdStatus, setPwdStatus] = useState<'idle'|'loading'|'success'|'error'>('idle')
const [disabling, setDisabling] = useState<string | null>(null)
const handleSetPassword = async () => {
if (!pwdModal || !newPwd.trim()) return
setPwdStatus('loading')
try {
await setUserPassword(pwdModal.name, newPwd.trim())
setPwdStatus('success')
setTimeout(() => { setPwdModal(null); setNewPwd(''); setPwdStatus('idle') }, 1200)
} catch { setPwdStatus('error') }
}
const handleDisable = async (name: string) => {
if (!window.confirm(t.users.disableConfirm)) return
setDisabling(name)
try { await onDisable?.(name) } finally { setDisabling(null) }
}
const allUsers = users.filter(u => {
if (statusFilter === 'active' && (u.enabled === 0 || u.enabled === '0')) return false
if (statusFilter === 'inactive' && u.enabled !== 0 && u.enabled !== '0') return false
const cat = u.custom_category || ''
if (catFilter && cat !== catFilter) return false
const org = u.custom_organization || ''
if (orgFilter && !org.toLowerCase().includes(orgFilter.toLowerCase())) return false
if (search) {
const q = search.toLowerCase()
const hay = [u.full_name, u.email, u.name, u.username, u.mobile_no].join(' ').toLowerCase()
if (!hay.includes(q)) return false
}
return true
})
const orgs = [...new Set(users.map(u => u.custom_organization).filter(Boolean))]
const categories = ['Admin', 'Chairman', 'Auditor', 'X-Auditor', 'Manager', 'Employee']
useEffect(() => { setPage(1) }, [search, orgFilter, catFilter, statusFilter])
const pageUsers = allUsers.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
useEffect(() => {
if (onSelectionChange) {
const sel = allUsers.filter(r => selectedIds.has(r.name))
onSelectionChange(sel, pageUsers)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIds, pageUsers])
const inp: React.CSSProperties = { padding: '8px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 13, background: 'white', width: '100%', fontFamily: 'inherit', outline: 'none', color: '#374151' }
const sel: React.CSSProperties = { ...inp, cursor: 'pointer' }
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1 }}>
{/* Filter bar */}
<div style={{ background: 'white', borderRadius: 12, overflow: 'hidden', boxShadow: '0 1px 4px rgba(0,0,0,0.08)', marginBottom: 16 }}>
<div
onClick={() => setFiltersOpen(o => !o)}
style={{ background: 'linear-gradient(90deg, #3b82f6 0%, #2563eb 100%)', padding: '12px 18px', display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round">{filtersOpen ? <polyline points="18,15 12,9 6,15"/> : <polyline points="6,9 12,15 18,9"/>}</svg>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
<span style={{ color: 'white', fontWeight: 700, fontSize: 14 }}>Filters</span>
</div>
{filtersOpen && (
<div style={{ padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 14 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', marginBottom: 5, textTransform: 'uppercase', letterSpacing: 0.5 }}>Search</div>
<input style={inp} placeholder="Name, email, username…" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} />
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', marginBottom: 5, textTransform: 'uppercase', letterSpacing: 0.5 }}>Organization</div>
<select style={sel} value={orgFilter} onChange={e => { setOrgFilter(e.target.value); setPage(1) }}>
<option value="">All Organizations</option>
{orgs.map(o => <option key={o} value={o}>{o}</option>)}
</select>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', marginBottom: 5, textTransform: 'uppercase', letterSpacing: 0.5 }}>Category</div>
<select style={sel} value={catFilter} onChange={e => { setCatFilter(e.target.value); setPage(1) }}>
<option value="">All Categories</option>
{categories.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', marginBottom: 5, textTransform: 'uppercase', letterSpacing: 0.5 }}>Status</div>
<select style={sel} value={statusFilter} onChange={e => { setStatusFilter(e.target.value); setPage(1) }}>
<option value="all">All</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
</div>
)}
</div>
{/* Users table */}
<div style={{ background: 'white', borderRadius: 10, border: '1px solid #e5e7eb', overflow: 'auto', flex: 1, boxShadow: '0 1px 4px rgba(0,0,0,0.05)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f9fafb', borderBottom: '2px solid #e5e7eb', position: 'sticky', top: 0 }}>
<th style={{ width: 40, padding: '11px 14px', textAlign: 'center' }}>
<input
type="checkbox"
checked={pageUsers.length > 0 && pageUsers.every(r => selectedIds.has(r.name))}
onChange={e => {
if (e.target.checked) {
setSelectedIds(new Set([...selectedIds, ...pageUsers.map(r => r.name)]))
} else {
const next = new Set(selectedIds)
pageUsers.forEach(r => next.delete(r.name))
setSelectedIds(next)
}
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</th>
{[
{ label: 'Category', w: 110 }, { label: 'Number', w: 90 }, { label: t.col.name, w: 160 },
{ label: 'Username', w: 130 }, { label: 'Email', w: 180 }, { label: 'Phone', w: 110 },
{ label: t.col.organization, w: 140 }, { label: t.col.management, w: 120 }, { label: t.col.department, w: 120 },
{ label: 'Branch', w: 100 }, { label: 'Since', w: 90 }, { label: 'Status', w: 80 }, { label: 'Tools', w: 200 },
].map(({ label, w }) => (
<th key={label} style={{ padding: '11px 14px', textAlign: 'left', color: '#6b7280', fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap', minWidth: w, letterSpacing: 0.3, textTransform: 'uppercase' }}>
{label}
</th>
))}
</tr>
</thead>
<tbody>
{allUsers.length === 0
? <tr><td colSpan={14} style={{ padding: '48px 20px', textAlign: 'center', color: '#aaa', fontSize: 14 }}>No users found</td></tr>
: pageUsers.map((u, i) => {
const cat = u.custom_category || ''
const catColor = ROLE_COLOR[cat] || '#6b7280'
return (
<tr key={u.name} onClick={() => onView?.(u)} style={{ borderBottom: '1px solid #f3f4f6', background: selectedIds.has(u.name) ? '#eff6ff' : (i % 2 === 0 ? 'white' : '#fafafa'), transition: 'background 0.1s', cursor: 'pointer' }}>
<td style={{ width: 40, padding: '11px 14px', textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(u.name)}
onChange={() => {
const next = new Set(selectedIds)
next.has(u.name) ? next.delete(u.name) : next.add(u.name)
setSelectedIds(next)
}}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: '#2563eb' }}
/>
</td>
<td style={{ padding: '11px 14px', whiteSpace: 'nowrap' }}>
{cat ? (
<span style={{ background: catColor + '18', color: catColor, border: `1px solid ${catColor}44`, padding: '3px 10px', borderRadius: 20, fontSize: 11, fontWeight: 700 }}>{cat}</span>
) : <span style={{ color: '#d1d5db' }}>—</span>}
</td>
<td style={{ padding: '11px 14px', color: '#6b7280', whiteSpace: 'nowrap' }}>{u.custom_number || <span style={{ color: '#d1d5db' }}>—</span>}</td>
<td style={{ padding: '11px 14px', fontWeight: 600, color: '#1a2332', whiteSpace: 'nowrap' }}>{u.full_name || u.first_name || '—'}</td>
<td style={{ padding: '11px 14px', color: '#4b5563', whiteSpace: 'nowrap' }}>{u.username || <span style={{ color: '#d1d5db' }}>—</span>}</td>
<td style={{ padding: '11px 14px', color: '#4b5563', whiteSpace: 'nowrap' }}>{u.email || u.name}</td>
<td style={{ padding: '11px 14px', color: '#6b7280', whiteSpace: 'nowrap' }}>{u.mobile_no || <span style={{ color: '#d1d5db' }}>—</span>}</td>
<td style={{ padding: '11px 14px', color: '#4b5563', whiteSpace: 'nowrap' }}>{u.custom_organization || <span style={{ color: '#d1d5db' }}>—</span>}</td>
<td style={{ padding: '11px 14px', color: '#6b7280', whiteSpace: 'nowrap' }}>{u.custom_management || <span style={{ color: '#d1d5db' }}>—</span>}</td>
<td style={{ padding: '11px 14px', color: '#6b7280', whiteSpace: 'nowrap' }}>{u.custom_department || <span style={{ color: '#d1d5db' }}>—</span>}</td>
<td style={{ padding: '11px 14px', color: '#6b7280', whiteSpace: 'nowrap' }}>{u.custom_branch || <span style={{ color: '#d1d5db' }}>—</span>}</td>
<td style={{ padding: '11px 14px', color: '#6b7280', whiteSpace: 'nowrap' }}>{formatDate(u.creation)}</td>
<td style={{ padding: '11px 14px', whiteSpace: 'nowrap' }}>
<span style={{ color: u.enabled ? '#16a34a' : '#dc2626', fontWeight: 600 }}>
{u.enabled ? t.users.active : t.users.inactive}
</span>
</td>
<td style={{ padding: '10px 14px', whiteSpace: 'nowrap' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<button onClick={e => { e.stopPropagation(); onView?.(u) }} title="View" style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', border: 'none', cursor: 'pointer', background: '#e0f7fa', color: '#0891b2' }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
{canModify && onEdit && <button onClick={e => { e.stopPropagation(); onEdit(u) }} title="Edit" style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', border: 'none', cursor: 'pointer', background: '#dcfce7', color: '#16a34a' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>}
{canModify && <button onClick={e => { e.stopPropagation(); setPwdModal({ name: u.name, email: u.email || u.name }); setNewPwd(''); setPwdStatus('idle') }} title="Set Password" style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', border: 'none', cursor: 'pointer', background: '#fef3c7', color: '#d97706' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</button>}
{canModify && <button onClick={e => { e.stopPropagation(); handleDisable(u.name) }} disabled={disabling === u.name} title="Disable" style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', border: 'none', cursor: 'pointer', background: '#fee2e2', color: '#ef4444', opacity: disabling === u.name ? 0.5 : 1 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<Pagination page={page} total={allUsers.length} onPageChange={setPage} />
{/* Password modal */}
{pwdModal && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ background: 'white', borderRadius: 14, padding: 32, width: 390, boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div style={{ fontSize: 17, fontWeight: 700, marginBottom: 6, color: '#1a2332' }}>{t.users.setPassword}</div>
<div style={{ fontSize: 13, color: '#6b7280', marginBottom: 20 }}>User: <strong>{pwdModal.email}</strong></div>
<input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)}
placeholder={t.users.newPassword}
style={{ width: '100%', padding: '10px 12px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: 13, boxSizing: 'border-box', marginBottom: 16, fontFamily: 'Segoe UI', outline: 'none' }}
onKeyDown={e => e.key === 'Enter' && handleSetPassword()} autoFocus />
{pwdStatus === 'success' && <div style={{ color: '#166534', background: '#f0fdf4', border: '1px solid #86efac', borderRadius: 6, padding: '8px 12px', marginBottom: 12, fontSize: 13 }}>Password updated successfully</div>}
{pwdStatus === 'error' && <div style={{ color: '#991b1b', background: '#fef2f2', border: '1px solid #fca5a5', borderRadius: 6, padding: '8px 12px', marginBottom: 12, fontSize: 13 }}>Failed to update password</div>}
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={handleSetPassword} disabled={pwdStatus === 'loading' || !newPwd.trim()}
style={{ flex: 1, padding: 10, background: '#2563eb', color: 'white', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 13, fontWeight: 700, fontFamily: 'inherit', opacity: !newPwd.trim() ? 0.5 : 1 }}>
{pwdStatus === 'loading' ? 'Saving…' : t.users.setPassword}
</button>
<button onClick={() => { setPwdModal(null); setNewPwd(''); setPwdStatus('idle') }}
style={{ flex: 1, padding: 10, background: '#f5f5f5', color: '#374151', border: '1px solid #e5e7eb', borderRadius: 8, cursor: 'pointer', fontSize: 13, fontFamily: 'inherit' }}>
{t.cancel}
</button>
</div>
</div>
</div>
)}
</div>
)
}

78
frontend/src/routing.ts Normal file
View File

@ -0,0 +1,78 @@
import type { Page } from './types'
export const ALL_PAGES: Page[] = [
'dashboard',
'incidents', 'incidents-form',
'ideas', 'ideas-form',
'orders', 'orders-form',
'processes', 'processes-form', 'processes-workflow',
'organizations', 'organizations-form',
'managements', 'managements-form',
'departments', 'departments-form',
'branches', 'branches-form',
'incident-types', 'incident-types-form',
'idea-types', 'idea-types-form',
'order-types', 'order-types-form',
'process-types', 'process-types-form',
'incident-statuses', 'incident-statuses-form',
'idea-statuses', 'idea-statuses-form',
'order-statuses', 'order-statuses-form',
'process-statuses', 'process-statuses-form',
'users', 'users-form',
'cycles', 'cycles-form',
'kpi-input', 'kpi-input-form',
'delegations', 'delegations-form',
'inbox',
'reports',
'profile',
]
const VALID_PAGES = new Set<string>(ALL_PAGES)
/** Form/editor pages render whatever record was loaded into the in-memory `*Form`
* state before navigating — state a fresh page load doesn't have. A deep link or
* refresh on one of these falls back to its list page instead of a blank form.
* (Back/forward within a session keeps the form state, so popstate skips this.) */
const FRESH_LOAD_FALLBACK: Partial<Record<Page, Page>> = {
'incidents-form': 'incidents',
'ideas-form': 'ideas',
'orders-form': 'orders',
'processes-form': 'processes',
'processes-workflow': 'processes',
'organizations-form': 'organizations',
'managements-form': 'managements',
'departments-form': 'departments',
'branches-form': 'branches',
'incident-types-form': 'incident-types',
'idea-types-form': 'idea-types',
'order-types-form': 'order-types',
'process-types-form': 'process-types',
'incident-statuses-form': 'incident-statuses',
'idea-statuses-form': 'idea-statuses',
'order-statuses-form': 'order-statuses',
'process-statuses-form': 'process-statuses',
'users-form': 'users',
'cycles-form': 'cycles',
'kpi-input-form': 'kpi-input',
'delegations-form': 'delegations',
// profile renders the `profileUser` selected before navigating — gone on reload
'profile': 'dashboard',
}
/** Read the requested page from the URL. Hash (`#/dashboard`) wins; a bare path
* typed directly into the dev server (`/dashboard`) is accepted as a fallback. */
export function parseRoute(): Page | null {
const h = window.location.hash.replace(/^#\/?/, '')
if (h) return VALID_PAGES.has(h) ? (h as Page) : null
const seg = window.location.pathname.split('/').filter(Boolean).pop() || ''
return VALID_PAGES.has(seg) ? (seg as Page) : null
}
export function freshLoadPage(p: Page | null): Page | null {
if (!p) return null
return FRESH_LOAD_FALLBACK[p] ?? p
}
export function buildHash(page: Page): string {
return `#/${page}`
}

283
frontend/src/types/index.ts Normal file
View File

@ -0,0 +1,283 @@
export interface SoftDeleteFields {
group_company?: string;
custom_is_deleted?: number;
custom_deleted_by?: string;
custom_deleted_on?: string;
custom_deleted_reason?: string;
}
export interface Incident extends SoftDeleteFields {
name: string; number: string; title: string; organization: string
event_category?: string; type: string; status: string; date: string; time: string
assigned_to: string; description: string
management: string; department: string; section_field: string; branch: string
workflow_state: string
// Creator data (auto-filled)
creator_name?: string; creator_id?: string
creator_division?: string; creator_department?: string
creator_section?: string; creator_branch?: string
// Auditor triage (Step 2)
incident_class?: string; hses_field?: string
priority?: string; economy_class?: string
product_relation?: number; repeated?: number; entity_scope?: string
// Manager solution (Step 3)
solution?: string; cost?: number; summary?: string
// Comment (Step 4)
auditor_comment?: string
deadline_date?: string
}
export interface Organization extends SoftDeleteFields {
name: string; organization_name: string; type: string; city: string; contact_email: string; is_active: number
creation_date?: string; status?: string; acronym?: string; group_company?: string
incident_triage_window_days?: number; incident_resolution_window_days?: number
}
export interface Idea extends SoftDeleteFields {
name: string; number: string; title: string; organization: string
type: string; status: string
management: string; department: string; section_field: string; branch: string
current_condition: string; proposed_condition: string
workflow_state: string
// Creator data
creator_name?: string; creator_id?: string
creator_division?: string; creator_department?: string
creator_section?: string; creator_branch?: string
// Classification
idea_class?: string; hses_field?: string; date?: string
// Auditor review (Step 2)
priority?: string; economy_class?: string
product_relation?: number; repeated?: number; entity_scope?: string
assigned_to?: string
// Manager solution (Step 3)
solution?: string; cost?: number
// Comment (Step 4)
auditor_comment?: string
sb_order?: string
}
export interface Order extends SoftDeleteFields {
name: string; number: string; title: string; organization: string
type: string; status: string; starting_date: string; ending_date: string
assigned_to: string; description: string
management: string; department: string; section_field: string; branch: string
budget: number; actual_cost: number; currency: string; notes: string
workflow_state: string
sb_idea?: string
// Classification
planned?: number; priority?: string; economy_class?: string
product_relation?: number; entity_scope?: string; hses_field?: string
// Comment
auditor_comment?: string
// Cycle
cycle?: string; actor?: string; stage?: number; completion_pct?: number; escalation_level?: number
cycle_type?: string
long_cycle_date_1?: string; long_cycle_date_2?: string
long_cycle_date_3?: string; long_cycle_date_4?: string
order_cycle_log?: OrderCycleLogRow[]
}
export interface Process extends SoftDeleteFields {
name: string; title: string; date_start: string; date_end: string
number: string; status: string; cycle_type: string; organization: string
type: string; management: string; department: string; section_field: string; branch: string
workflow_state?: string
// Classification
chain_location?: string; economy_class?: string; impact_level?: string
product_relation?: number; entity_scope?: string; hses_field?: string
// KPI
kpi_name?: string; kpi_type?: string; kpi_measure?: string
// Cycle configuration
cycle?: string; actor?: string; cost_per_cycle?: number
long_cycle_date_1?: string; long_cycle_date_2?: string
long_cycle_date_3?: string; long_cycle_date_4?: string
escalation_level?: number
// Comment
auditor_comment?: string
// Child tables
progress_indicators?: any[]
cycle_logs?: ProcessCycleLogRow[]
// Internal
study_incident?: string
}
export interface Management extends SoftDeleteFields {
name: string
organization: string
name1: string
creation_date?: string
status?: string
}
export interface Department extends SoftDeleteFields {
name: string
custom_organization: string
department_name: string
custom_management: string
custom_creation_date?: string
custom_status?: string
}
export interface Branch extends SoftDeleteFields {
name: string
organization: string
branch: string
management: string
department: string
custom_creation_date?: string
custom_status?: string
}
export interface SBCycle extends SoftDeleteFields {
name: string
name1: string
organization: string
frequency: string
status: string
start_date: string
end_date: string
update_window_days: number
}
export interface SBKpiInput extends SoftDeleteFields {
name: string
organization: string
period: string
revenue_growth: number
net_profit: number
cash_flow: number
strategic_goal_pct: number
customer_satisfaction: number
employee_turnover_pct: number
open_safety_incidents: number
compliance_score: number
}
export interface ProcessCycleLogRow {
name?: string
cycle: string
cycle_date: string
status: 'Pass' | 'Fail' | 'Unknown'
actor: string
comment?: string
}
export interface OrderCycleLogRow {
name?: string
cycle: string
cycle_date: string
stage: number
status: 'Agree' | 'Deviate' | 'Unknown'
actor: string
comment?: string
}
export interface InboxItem {
type: 'process' | 'order' | 'incident' | 'idea' | 'delegation'
action: string
name: string
number: string
title: string
organization: string
date: string
requires_action: boolean
}
export interface DelegationLogRow {
name?: string
action: 'Created' | 'Accepted' | 'Rejected' | 'Revoked'
actor: string
timestamp: string
remarks?: string
}
export interface Delegation {
name: string
number: string
auditor: string
manager: string
organization: string
incident_initial_evaluation?: number
idea_initial_evaluation?: number
incident_name?: string // specific incident to delegate (optional)
idea_name?: string // specific idea to delegate (optional)
status: 'Pending' | 'Accepted' | 'Rejected' | 'Revoked'
requested_on: string
responded_on?: string
revoked_on?: string
delegation_log?: DelegationLogRow[]
remarks?: string
}
export interface IncidentType { name: string; name1: string; creation_date?: string; status?: string }
export interface IncidentClass { name: string; name1: string }
export interface IncidentField { name: string; name1: string }
export interface IdeaType { name: string; name1: string; creation_date?: string; status?: string }
export interface OrderType { name: string; name1: string; creation_date?: string; status?: string }
export interface ProcessType { name: string; name1: string; creation_date?: string; status?: string }
export interface IncidentStatus { name: string; name1: string; notify_manager?: number; creation_date?: string; status?: string }
export interface IdeaStatus { name: string; name1: string; notify_manager?: number; creation_date?: string; status?: string }
export interface OrderStatus { name: string; name1: string; notify_manager?: number; creation_date?: string; status?: string }
export interface ProcessStatus { name: string; name1: string; notify_manager?: number; creation_date?: string; status?: string }
export interface SBGroupCompany extends SoftDeleteFields {
name: string
company_name: string
acronym?: string
code?: string
status?: string
owner_user?: string
notes?: string
}
export interface User {
name: string; full_name: string; role: string; custom_organization?: string; custom_group_company?: string; custom_manager?: string
}
export interface SBUser {
name: string
first_name: string
full_name: string
email: string
enabled: number
creation: string
username: string
mobile_no: string
custom_organization: string
custom_group_company?: string
custom_manager?: string
custom_management: string
custom_department: string
custom_branch: string
custom_number: string
custom_category: string
new_password?: string
}
export type Page =
| 'dashboard'
| 'incidents' | 'incidents-form'
| 'ideas' | 'ideas-form'
| 'orders' | 'orders-form'
| 'processes' | 'processes-form' | 'processes-workflow'
| 'organizations' | 'organizations-form'
| 'group-companies' | 'group-companies-form'
| 'managements' | 'managements-form'
| 'departments' | 'departments-form'
| 'branches' | 'branches-form'
| 'incident-types' | 'incident-types-form'
| 'idea-types' | 'idea-types-form'
| 'order-types' | 'order-types-form'
| 'process-types' | 'process-types-form'
| 'incident-statuses' | 'incident-statuses-form'
| 'idea-statuses' | 'idea-statuses-form'
| 'order-statuses' | 'order-statuses-form'
| 'process-statuses' | 'process-statuses-form'
| 'users' | 'users-form'
| 'cycles' | 'cycles-form'
| 'kpi-input' | 'kpi-input-form'
| 'delegations' | 'delegations-form'
| 'inbox'
| 'reports'
| 'profile'

48
frontend/src/utils.ts Normal file
View File

@ -0,0 +1,48 @@
// src/utils/api.ts
const API_URL = import.meta.env.VITE_API_URL
let csrfToken = 'fetch'
export const API = (method: string) => `/api/method/sidrah_business.sidrah_business.api.${method}`
export async function post(url: string, data: Record<string, string>) {
const res = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'X-Frappe-CSRF-Token': csrfToken, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(data)
})
return res.json()
}
export function get(method: string) {
return fetch(API(method), {
credentials: 'include',
headers: { 'X-Frappe-CSRF-Token': csrfToken }
}).then(r => r.json())
}
export async function loginUser(usr: string, pwd: string) {
const res = await fetch(`${API_URL}/api/method/login`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ usr, pwd })
})
const data = await res.json()
if (data.message === 'Logged In') {
const c = document.cookie.split(';').find(x => x.trim().startsWith('csrftoken='))
if (c) csrfToken = c.split('=')[1].trim()
}
return data
}
export async function logoutUser() {
return fetch('/api/method/logout', { method: 'POST', credentials: 'include' })
}
export async function getUserRoles(user: string) {
const res = await fetch(API('get_user_roles') + `?user=${encodeURIComponent(user)}`, { credentials: 'include' })
return res.json()
}

240
frontend/src/utils/api.ts Normal file
View File

@ -0,0 +1,240 @@
// src/utils/api.ts
// ============================================================================
// TypeScript Interfaces
// ============================================================================
export interface Incident {
name: string
code: string
title: string
organization: string
type: 'Incident' | 'Complaint' | 'Violation'
status: 'New' | 'Under Progress' | 'Executed' | 'Overdue'
date: string
time: string
deadline_date: string
assigned_to: string
description?: string
doctype?: string
docstatus?: number
}
export interface Organization {
name: string
organization_name: string
type: 'Government' | 'Private' | 'Non-Profit'
city: string
is_active: number // 0 or 1
contact_email?: string
doctype?: string
}
export interface Idea {
name: string
code: string
title: string
organization: string
category: 'Idea' | 'Study' | 'Suggestion'
status: 'New' | 'Under Review' | 'Approved' | 'Rejected'
date: string
submitted_by: string
description?: string
doctype?: string
}
export interface Order {
name: string
code: string
title: string
organization: string
type: 'Outsourcing' | 'Procurement' | 'Service'
status: 'New' | 'In Review' | 'Approved' | 'Rejected' | 'Completed'
date: string
deadline_date: string
submitted_by: string
description?: string
doctype?: string
}
export interface User {
name: string
full_name: string
role: 'Manager' | 'Auditor' | 'Employee'
}
export interface FrappeUser {
name: string
full_name: string
email: string
}
// ============================================================================
// API Configuration
// ============================================================================
export const API = (method: string): string =>
`/api/method/sidrah_business.sidrah_business.api.${method}`
export const FRAPPE = (method: string): string =>
`/api/method/${method}`
// ============================================================================
// CSRF Token Management
// ============================================================================
let csrfToken = 'fetch'
export function initCSRF(): void {
const cookie = document.cookie
.split(';')
.find(x => x.trim().startsWith('csrftoken='))
if (cookie) {
csrfToken = cookie.split('=')[1].trim()
}
}
export function setCSRF(token: string): void {
csrfToken = token
}
// ============================================================================
// API Request Functions
// ============================================================================
export async function post(
url: string,
data: Record<string, any>
): Promise<any> {
const res = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: {
'X-Frappe-CSRF-Token': csrfToken,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams(data)
})
if (!res.ok) {
throw new Error(`API Error: ${res.status}`)
}
return res.json()
}
export function get(method: string): Promise<any> {
return fetch(API(method), {
credentials: 'include',
headers: { 'X-Frappe-CSRF-Token': csrfToken }
})
.then(r => {
if (!r.ok) throw new Error(`API Error: ${r.status}`)
return r.json()
})
.catch(err => {
console.error('GET error:', err)
throw err
})
}
// ============================================================================
// Specific API Calls (Optional, for convenience)
// ============================================================================
export async function createIncident(data: Partial<Incident>): Promise<any> {
return post(API('create_incident'), data as Record<string, any>)
}
export async function createIdea(data: Partial<Idea>): Promise<any> {
return post(API('create_idea'), data as Record<string, any>)
}
export async function createOrder(data: Partial<Order>): Promise<any> {
return post(API('create_order'), data as Record<string, any>)
}
export async function createOrganization(data: Partial<Organization>): Promise<any> {
return post(API('create_organization'), data as Record<string, any>)
}
export async function getIncidents(): Promise<Incident[]> {
const res = await get('get_incidents')
return res.message || []
}
export async function getIdeas(): Promise<Idea[]> {
const res = await get('get_ideas')
return res.message || []
}
export async function getOrders(): Promise<Order[]> {
const res = await get('get_orders')
return res.message || []
}
export async function getOrganizations(): Promise<Organization[]> {
const res = await get('get_organizations')
return res.message || []
}
export async function getUsers(): Promise<FrappeUser[]> {
const res = await get('get_users')
return res.message || []
}
export async function getUserRoles(user: string): Promise<string[]> {
const res = await get(`get_user_roles?user=${encodeURIComponent(user)}`)
return res.message || []
}
export async function login(username: string, password: string): Promise<any> {
const res = await fetch('/api/method/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ usr: username, pwd: password })
})
return res.json()
}
export async function logout(): Promise<any> {
return fetch('/api/method/logout', {
method: 'POST',
credentials: 'include'
}).then(r => r.json())
}
// ============================================================================
// Type Guards
// ============================================================================
export function isIncident(data: any): data is Incident {
return data && 'type' in data && data.type && ['Incident', 'Complaint', 'Violation'].includes(data.type)
}
export function isIdea(data: any): data is Idea {
return data && 'category' in data && data.category && ['Idea', 'Study', 'Suggestion'].includes(data.category)
}
export function isOrder(data: any): data is Order {
return data && 'type' in data && data.type && ['Outsourcing', 'Procurement', 'Service'].includes(data.type)
}
export function isOrganization(data: any): data is Organization {
return data && 'organization_name' in data && 'is_active' in data
}
// ============================================================================
// Export All for Convenience
// ============================================================================
export const Types = {
Incident: {} as Incident,
Idea: {} as Idea,
Order: {} as Order,
Organization: {} as Organization,
User: {} as User,
FrappeUser: {} as FrappeUser
}

View File

@ -0,0 +1,42 @@
export interface ExportColumn {
key: string
label: string
}
function cellValue(row: any, col: ExportColumn): string {
const v = row[col.key]
if (v === null || v === undefined || v === '') return ''
if (typeof v === 'object') return JSON.stringify(v)
return String(v)
}
function triggerDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
export function exportToCSV(columns: ExportColumn[], rows: any[], filenameBase: string) {
const header = columns.map(c => `"${c.label.replace(/"/g, '""')}"`).join(',')
const body = rows.map(row => columns.map(c => `"${cellValue(row, c).replace(/"/g, '""')}"`).join(',')).join('\n')
const blob = new Blob([header + '\n' + body], { type: 'text/csv;charset=utf-8;' })
triggerDownload(blob, `${filenameBase}.csv`)
}
// Lazy-loaded — the xlsx library is ~300kB, no reason to ship it in the main bundle
// for users who only ever export CSV.
export async function exportToXLSX(columns: ExportColumn[], rows: any[], filenameBase: string) {
const XLSX = await import('xlsx')
const data = rows.map(row => {
const record: Record<string, string> = {}
for (const c of columns) record[c.label] = cellValue(row, c)
return record
})
const worksheet = XLSX.utils.json_to_sheet(data, { header: columns.map(c => c.label) })
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, worksheet, 'Export')
XLSX.writeFile(workbook, `${filenameBase}.xlsx`)
}

View File

@ -0,0 +1,115 @@
import { API_BASE_URL } from '../constants'
import { getCsrfToken, fetchCSRFToken } from '../api'
async function ensureCSRF(): Promise<string> {
const cached = getCsrfToken()
if (cached) return cached
return fetchCSRFToken()
}
export async function fetchProcess(name: string): Promise<ProcessDoc> {
// Use the custom endpoint to bypass permlevel restrictions on child table fields
const res = await fetch(
`${API_BASE_URL}/api/method/sidrah_business.api.get_process?name=${encodeURIComponent(name)}`,
{ credentials: 'include', headers: { Accept: 'application/json' } }
)
if (!res.ok) {
const json = await res.json().catch(() => ({}))
throw new Error(json.message || `Failed to load process (${res.status})`)
}
const json = await res.json()
return (json.message || json.data) as ProcessDoc
}
export async function createProcess(data: Record<string, unknown>): Promise<ProcessDoc> {
const token = await ensureCSRF()
const res = await fetch(`${API_BASE_URL}/api/resource/SB Process`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Frappe-CSRF-Token': token,
},
body: JSON.stringify(data),
})
if (!res.ok) {
const json = await res.json().catch(() => ({}))
throw new Error(json.message || json.exc || `Create failed (${res.status})`)
}
const json = await res.json()
return json.data as ProcessDoc
}
export async function applyAction(
name: string,
action: string,
extraFields: Record<string, unknown> = {}
): Promise<ProcessDoc> {
const token = await ensureCSRF()
const params = new URLSearchParams({ name, action })
if (extraFields.actor_status) params.set('actor_status', String(extraFields.actor_status))
if (extraFields.actor_comment) params.set('actor_comment', String(extraFields.actor_comment))
if (extraFields.progress_indicators) {
params.set('progress_indicators', JSON.stringify(extraFields.progress_indicators))
}
const res = await fetch(
`${API_BASE_URL}/api/method/sidrah_business.api.apply_process_workflow`,
{
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
'X-Frappe-CSRF-Token': token,
},
body: params.toString(),
}
)
if (!res.ok) {
const json = await res.json().catch(() => ({}))
const msg =
json.message ||
(json.exception ? String(json.exception).split('\n').pop() : '') ||
json.exc_type ||
`Workflow action "${action}" failed (${res.status})`
throw new Error(msg)
}
const json = await res.json()
return (json.message || json.data) as ProcessDoc
}
export function getUserRoles(): string[] {
return (window as Window & { frappe?: { user_roles?: string[] } }).frappe?.user_roles ?? []
}
// ---- Shared types --------------------------------------------------------
export interface ProgressRow {
name?: string
name1: string
type: string
unit: number | string
cycle: string
target: string
minimum: string
maximum: string
manager: string
actual_value?: number | string
cycle_status?: string
cycle_date?: string
cycle_comment?: string
status?: string
}
export interface ProcessDoc {
name: string
title: string
number: number
workflow_state: string
study_incident?: string
progress_indicators: ProgressRow[]
[key: string]: unknown
}

Some files were not shown because too many files have changed in this diff Show More