Initial commit of Sidrah Audit App
This commit is contained in:
commit
93b9781aeb
21
.editorconfig
Normal file
21
.editorconfig
Normal 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
124
.eslintrc
Normal 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
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
.DS_Store
|
||||||
|
*.pyc
|
||||||
|
*.egg-info
|
||||||
|
*.swp
|
||||||
|
tags
|
||||||
|
node_modules
|
||||||
|
__pycache__
|
||||||
69
.pre-commit-config.yaml
Normal file
69
.pre-commit-config.yaml
Normal 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
33
README.md
Normal 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
1
frontend/.env
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=http://172.19.127.96:8000
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
106
frontend/README.md
Normal 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
28
frontend/eslint.config.js
Normal 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
16
frontend/index.html
Normal 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
3327
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal 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
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 321 KiB |
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal 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
42
frontend/src/App.css
Normal 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
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
612
frontend/src/api/index.ts
Normal 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
|
||||||
|
}
|
||||||
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 321 KiB |
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
227
frontend/src/components/ActivityLog.tsx
Normal file
227
frontend/src/components/ActivityLog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
239
frontend/src/components/AppSidebar.tsx
Normal file
239
frontend/src/components/AppSidebar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
191
frontend/src/components/CommentSection.tsx
Normal file
191
frontend/src/components/CommentSection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
frontend/src/components/DateInput.tsx
Normal file
51
frontend/src/components/DateInput.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
285
frontend/src/components/DeleteConfirmModal.tsx
Normal file
285
frontend/src/components/DeleteConfirmModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
207
frontend/src/components/ExportModal.tsx
Normal file
207
frontend/src/components/ExportModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
frontend/src/components/FilterBar.tsx
Normal file
55
frontend/src/components/FilterBar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
frontend/src/components/FilterSection.tsx
Normal file
44
frontend/src/components/FilterSection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
336
frontend/src/components/FormPageLayout.tsx
Normal file
336
frontend/src/components/FormPageLayout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
frontend/src/components/FormSection.tsx
Normal file
17
frontend/src/components/FormSection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
393
frontend/src/components/LinkSelect.tsx
Normal file
393
frontend/src/components/LinkSelect.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
138
frontend/src/components/NotificationBell.tsx
Normal file
138
frontend/src/components/NotificationBell.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
415
frontend/src/components/OrgSelect.tsx
Normal file
415
frontend/src/components/OrgSelect.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
222
frontend/src/components/Pagination.tsx
Normal file
222
frontend/src/components/Pagination.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
488
frontend/src/components/ProcessWorkflow.tsx
Normal file
488
frontend/src/components/ProcessWorkflow.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
frontend/src/components/StatCard.tsx
Normal file
14
frontend/src/components/StatCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
87
frontend/src/components/StatusCard.tsx
Normal file
87
frontend/src/components/StatusCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
106
frontend/src/components/TableSelectionBar.tsx
Normal file
106
frontend/src/components/TableSelectionBar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
223
frontend/src/components/TopBar.tsx
Normal file
223
frontend/src/components/TopBar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
frontend/src/components/UserMenu.tsx
Normal file
127
frontend/src/components/UserMenu.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
287
frontend/src/components/WorkflowPanel.tsx
Normal file
287
frontend/src/components/WorkflowPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
643
frontend/src/components/common.tsx
Normal file
643
frontend/src/components/common.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
frontend/src/components/header.tsx
Normal file
40
frontend/src/components/header.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
99
frontend/src/components/sidebar.tsx
Normal file
99
frontend/src/components/sidebar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
253
frontend/src/constants/index.ts
Normal file
253
frontend/src/constants/index.ts
Normal 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
838
frontend/src/i18n.ts
Normal 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
792
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
135
frontend/src/pages/BranchFormPage.tsx
Normal file
135
frontend/src/pages/BranchFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
frontend/src/pages/BranchPage.tsx
Normal file
157
frontend/src/pages/BranchPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
132
frontend/src/pages/CycleFormPage.tsx
Normal file
132
frontend/src/pages/CycleFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
154
frontend/src/pages/CyclesPage.tsx
Normal file
154
frontend/src/pages/CyclesPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
685
frontend/src/pages/DashboardPage.tsx
Normal file
685
frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
230
frontend/src/pages/DelegationFormPage.tsx
Normal file
230
frontend/src/pages/DelegationFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
196
frontend/src/pages/DelegationsPage.tsx
Normal file
196
frontend/src/pages/DelegationsPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
frontend/src/pages/DepartmentFormPage.tsx
Normal file
121
frontend/src/pages/DepartmentFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
148
frontend/src/pages/DepartmentPage.tsx
Normal file
148
frontend/src/pages/DepartmentPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
frontend/src/pages/GroupCompaniesPage.tsx
Normal file
143
frontend/src/pages/GroupCompaniesPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
154
frontend/src/pages/GroupCompanyFormPage.tsx
Normal file
154
frontend/src/pages/GroupCompanyFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
590
frontend/src/pages/IdeaFormPage.tsx
Normal file
590
frontend/src/pages/IdeaFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
frontend/src/pages/IdeaStatusFormPage.tsx
Normal file
108
frontend/src/pages/IdeaStatusFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
frontend/src/pages/IdeaStatusPage.tsx
Normal file
141
frontend/src/pages/IdeaStatusPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
frontend/src/pages/IdeaTypeFormPage.tsx
Normal file
94
frontend/src/pages/IdeaTypeFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
frontend/src/pages/IdeaTypePage.tsx
Normal file
139
frontend/src/pages/IdeaTypePage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
196
frontend/src/pages/IdeasPage.tsx
Normal file
196
frontend/src/pages/IdeasPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
595
frontend/src/pages/InboxPage.tsx
Normal file
595
frontend/src/pages/InboxPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
574
frontend/src/pages/IncidentFormPage.tsx
Normal file
574
frontend/src/pages/IncidentFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
frontend/src/pages/IncidentStatusFormPage.tsx
Normal file
108
frontend/src/pages/IncidentStatusFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
frontend/src/pages/IncidentStatusPage.tsx
Normal file
141
frontend/src/pages/IncidentStatusPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
frontend/src/pages/IncidentTypeFormPage.tsx
Normal file
94
frontend/src/pages/IncidentTypeFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
frontend/src/pages/IncidentTypePage.tsx
Normal file
139
frontend/src/pages/IncidentTypePage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
189
frontend/src/pages/IncidentsPage.tsx
Normal file
189
frontend/src/pages/IncidentsPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
131
frontend/src/pages/KpiInputFormPage.tsx
Normal file
131
frontend/src/pages/KpiInputFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
135
frontend/src/pages/KpiInputPage.tsx
Normal file
135
frontend/src/pages/KpiInputPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
249
frontend/src/pages/LoginPage.tsx
Normal file
249
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
102
frontend/src/pages/ManagementFormPage.tsx
Normal file
102
frontend/src/pages/ManagementFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
frontend/src/pages/ManagementPage.tsx
Normal file
139
frontend/src/pages/ManagementPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
715
frontend/src/pages/OrderFormPage.tsx
Normal file
715
frontend/src/pages/OrderFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
frontend/src/pages/OrderStatusFormPage.tsx
Normal file
108
frontend/src/pages/OrderStatusFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
frontend/src/pages/OrderStatusPage.tsx
Normal file
141
frontend/src/pages/OrderStatusPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
frontend/src/pages/OrderTypeFormPage.tsx
Normal file
94
frontend/src/pages/OrderTypeFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
frontend/src/pages/OrderTypePage.tsx
Normal file
139
frontend/src/pages/OrderTypePage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
198
frontend/src/pages/OrdersPage.tsx
Normal file
198
frontend/src/pages/OrdersPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
frontend/src/pages/OrganizationFormPage.tsx
Normal file
139
frontend/src/pages/OrganizationFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
149
frontend/src/pages/OrganizationsPage.tsx
Normal file
149
frontend/src/pages/OrganizationsPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
575
frontend/src/pages/ProcessFormPage.tsx
Normal file
575
frontend/src/pages/ProcessFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
frontend/src/pages/ProcessStatusFormPage.tsx
Normal file
108
frontend/src/pages/ProcessStatusFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
frontend/src/pages/ProcessStatusPage.tsx
Normal file
141
frontend/src/pages/ProcessStatusPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
frontend/src/pages/ProcessTypeFormPage.tsx
Normal file
94
frontend/src/pages/ProcessTypeFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
frontend/src/pages/ProcessTypePage.tsx
Normal file
139
frontend/src/pages/ProcessTypePage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
189
frontend/src/pages/ProcessesPage.tsx
Normal file
189
frontend/src/pages/ProcessesPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
353
frontend/src/pages/ReportsPage.tsx
Normal file
353
frontend/src/pages/ReportsPage.tsx
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
335
frontend/src/pages/UserFormPage.tsx
Normal file
335
frontend/src/pages/UserFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
473
frontend/src/pages/UserProfilePage.tsx
Normal file
473
frontend/src/pages/UserProfilePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
263
frontend/src/pages/UsersPage.tsx
Normal file
263
frontend/src/pages/UsersPage.tsx
Normal 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
78
frontend/src/routing.ts
Normal 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
283
frontend/src/types/index.ts
Normal 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
48
frontend/src/utils.ts
Normal 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
240
frontend/src/utils/api.ts
Normal 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
|
||||||
|
}
|
||||||
42
frontend/src/utils/export.ts
Normal file
42
frontend/src/utils/export.ts
Normal 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`)
|
||||||
|
}
|
||||||
115
frontend/src/utils/processAPI.ts
Normal file
115
frontend/src/utils/processAPI.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user