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