310 lines
10 KiB
JavaScript
310 lines
10 KiB
JavaScript
import { useMemo, useState } from 'react'
|
|
import { useNavigate, useParams } from 'react-router-dom'
|
|
import {
|
|
Avatar,
|
|
BackLink,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
EmptyState,
|
|
Icon,
|
|
InfoList,
|
|
InfoRow,
|
|
PageHeader,
|
|
} from '../../components/ui'
|
|
import useDetailView from '../../hooks/useDetailView'
|
|
import { DETAIL_VIEWS, detailValue } from '../../api/detailView'
|
|
import { toISODate } from '../../api/recordView'
|
|
import { instanceState, isEndState } from '../../api/activityForm'
|
|
import FieldValue, { formatMeta } from '../../components/activity/FieldValue'
|
|
import {
|
|
ALL_EMPLOYEE_FIELDS,
|
|
EMPLOYEE_UPDATE_FIELDS,
|
|
withLatestInstanceValues,
|
|
} from '../../api/employees'
|
|
import { EMPLOYEE_STATUS_TONE } from '../../constants'
|
|
import UpdateEmployeeModal from './UpdateEmployeeModal'
|
|
import InactiveEmployeeModal from './InactiveEmployeeModal'
|
|
|
|
const VIEW = DETAIL_VIEWS.employees
|
|
|
|
/**
|
|
* INIT ids first, Update ids last, so when a record holds both (field_2 and
|
|
* field_10 for email) the value from the latest Update wins.
|
|
*/
|
|
const FIELD_DEFS = ALL_EMPLOYEE_FIELDS
|
|
|
|
/**
|
|
* Employee details, read live from the workflow's detail view.
|
|
* Route: /employees/:instanceId — the id of the employee's workflow instance.
|
|
*/
|
|
export default function EmployeeDetails() {
|
|
const { instanceId } = useParams()
|
|
const navigate = useNavigate()
|
|
// The detail template can return old values after an Update, so merge in
|
|
// the workflow instance, which always holds what was saved.
|
|
const { view, loading, error, reload } = useDetailView(VIEW.templateUid, instanceId, {
|
|
fieldDefs: FIELD_DEFS,
|
|
enrich: withLatestInstanceValues,
|
|
})
|
|
const [updating, setUpdating] = useState(false)
|
|
const [inactivating, setInactivating] = useState(false)
|
|
const [notice, setNotice] = useState(null)
|
|
|
|
// Row for the Update dialog: values keyed by field label (what it pre-fills
|
|
// from) plus the instance id, so it skips the lookup.
|
|
const updateRow = useMemo(() => {
|
|
if (!view) return null
|
|
const row = {}
|
|
for (const section of view.sections) {
|
|
for (const f of section.fields) if (f.value != null) row[f.label] = f.value
|
|
}
|
|
for (const def of FIELD_DEFS) {
|
|
const v = view.record[def.id]
|
|
if (v !== undefined && v !== null && v !== '') row[def.label] = v
|
|
}
|
|
return { ...row, instance_id: instanceId }
|
|
}, [view, instanceId])
|
|
|
|
const back = () => navigate('/employees')
|
|
|
|
if (loading && !view) {
|
|
return (
|
|
<>
|
|
<BackLink onClick={back}>Back to Employees</BackLink>
|
|
<Card>
|
|
<EmptyState icon="⏳" title="Loading employee" text="Fetching from the workflow…" />
|
|
</Card>
|
|
</>
|
|
)
|
|
}
|
|
|
|
if (error || !view) {
|
|
return (
|
|
<>
|
|
<BackLink onClick={back}>Back to Employees</BackLink>
|
|
<Card>
|
|
<EmptyState
|
|
icon="🙁"
|
|
title="Could not load this employee"
|
|
text={error || 'The detail view returned nothing for this record.'}
|
|
action={
|
|
<div className="row">
|
|
<Button onClick={reload}>Try again</Button>
|
|
<Button variant="primary" onClick={back}>
|
|
Back to Employees
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
</Card>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const firstName = detailValue(view, 'first_name', 'first name')
|
|
const lastName = detailValue(view, 'last_name', 'last name')
|
|
const name = [firstName, lastName].filter(Boolean).join(' ').trim() || 'Employee'
|
|
const designation = detailValue(view, 'designation')
|
|
const department = detailValue(view, 'department')
|
|
const code = detailValue(view, 'employee_code', 'employee code', 'ID Generator')
|
|
const status = detailValue(view, 'status')
|
|
const email = detailValue(view, 'email')
|
|
const phone = detailValue(view, 'phone')
|
|
|
|
// The instance carries current_state_name. Inactive moves it to End State,
|
|
// which rejects every activity, so don't offer them there.
|
|
const finished = isEndState(view.record)
|
|
const subtitle = [designation, department, code].filter(Boolean).join(' · ')
|
|
const empty = view.sections.every((s) => s.fields.length === 0)
|
|
|
|
/** `values` is keyed by the update field ids — see EMPLOYEE_UPDATE_FIELDS. */
|
|
async function handleUpdated(values) {
|
|
setNotice(null)
|
|
const next = await reload()
|
|
// Read the employee back and say plainly if any saved value didn't come back.
|
|
const stale = next ? staleFields(next, values) : []
|
|
setNotice(
|
|
stale.length
|
|
? {
|
|
tone: 'warning',
|
|
text:
|
|
`Saved, but the workflow still returns the old value for ${stale.join(', ')}. ` +
|
|
'The Zino views are not reading these Update fields yet.',
|
|
}
|
|
: { tone: 'success', text: 'Employee was updated.' },
|
|
)
|
|
}
|
|
|
|
async function handleInactivated() {
|
|
setNotice({ tone: 'success', text: `${name} was marked inactive.` })
|
|
await reload()
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<BackLink onClick={back}>Back to Employees</BackLink>
|
|
|
|
<PageHeader
|
|
title={name}
|
|
subtitle={subtitle || undefined}
|
|
actions={
|
|
<>
|
|
<Button icon="history" onClick={reload} disabled={loading}>
|
|
{loading ? 'Refreshing…' : 'Refresh'}
|
|
</Button>
|
|
<Button
|
|
variant="danger"
|
|
icon="close"
|
|
onClick={() => setInactivating(true)}
|
|
disabled={finished}
|
|
>
|
|
Mark Inactive
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
icon="check"
|
|
onClick={() => setUpdating(true)}
|
|
disabled={finished}
|
|
>
|
|
Update Employee
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{finished && (
|
|
<div className="notice notice--warning" role="status">
|
|
<Icon name="alert" size={15} />
|
|
<span>
|
|
This employee has been marked inactive, so the workflow is in "
|
|
{instanceState(view.record)}" and has finished. It can't be updated or marked inactive
|
|
again.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{notice && (
|
|
<div className={`notice notice--${notice.tone}`} role="status">
|
|
<Icon name={notice.tone === 'success' ? 'check' : 'alert'} size={15} />
|
|
<span>{notice.text}</span>
|
|
<button
|
|
type="button"
|
|
className="notice__close"
|
|
onClick={() => setNotice(null)}
|
|
aria-label="Dismiss"
|
|
>
|
|
<Icon name="close" size={14} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="detail-grid">
|
|
{/* ---------- Left: profile + record info ---------- */}
|
|
<div className="stack">
|
|
<Card flush>
|
|
<div className="profile">
|
|
<Avatar name={name} size="lg" />
|
|
<div>
|
|
<div className="profile__name">{name}</div>
|
|
{designation && <div className="profile__role">{designation}</div>}
|
|
{status && <Badge tone={statusTone(status)}>{String(status)}</Badge>}
|
|
</div>
|
|
</div>
|
|
<InfoList>
|
|
<InfoRow label="Email">
|
|
{email ? <a className="link" href={`mailto:${email}`}>{String(email)}</a> : null}
|
|
</InfoRow>
|
|
<InfoRow label="Phone">{phone != null ? String(phone) : null}</InfoRow>
|
|
<InfoRow label="Employee code">{code != null ? String(code) : null}</InfoRow>
|
|
</InfoList>
|
|
</Card>
|
|
|
|
{view.meta.length > 0 && (
|
|
<Card title="Record info" flush>
|
|
<InfoList>
|
|
{view.meta.map((m) => (
|
|
<InfoRow key={m.key} label={m.label}>
|
|
<span className="cell-mono">{formatMeta(m.value)}</span>
|
|
</InfoRow>
|
|
))}
|
|
</InfoList>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
|
|
{/* ---------- Right: every field the detail view returns ---------- */}
|
|
<div className="stack">
|
|
{empty ? (
|
|
<Card>
|
|
<EmptyState
|
|
icon="📭"
|
|
title="No details"
|
|
text="The detail view returned no fields for this employee."
|
|
/>
|
|
</Card>
|
|
) : (
|
|
view.sections.map((section, i) => (
|
|
<Card key={section.title || i} title={section.title || 'Employee details'} flush>
|
|
<dl className="detail-fields">
|
|
{section.fields.map((f) => (
|
|
<div key={f.key ?? f.label} className="detail-field">
|
|
<dt className="detail-field__label">{f.label}</dt>
|
|
<dd className="detail-field__value">
|
|
<FieldValue field={f} />
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</Card>
|
|
))
|
|
)}
|
|
|
|
<details className="detail-raw">
|
|
<summary>Raw response</summary>
|
|
<pre>
|
|
{JSON.stringify({ detail_view: view.raw, workflow_instance: view.instance ?? null }, null, 2)}
|
|
</pre>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
|
|
<UpdateEmployeeModal
|
|
open={updating}
|
|
row={updateRow}
|
|
onClose={() => setUpdating(false)}
|
|
onUpdated={handleUpdated}
|
|
/>
|
|
|
|
<InactiveEmployeeModal
|
|
open={inactivating}
|
|
instanceId={instanceId}
|
|
onClose={() => setInactivating(false)}
|
|
onDone={handleInactivated}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
/** Labels of the fields that were sent but don't read back with that value. */
|
|
function staleFields(view, values) {
|
|
const norm = (v, type) => {
|
|
if (type === 'date') return toISODate(v)
|
|
if (type === 'number') return String(v ?? '').replace(/[^\d.-]/g, '')
|
|
return String(v ?? '').trim().toLowerCase()
|
|
}
|
|
return EMPLOYEE_UPDATE_FIELDS.filter((f) => {
|
|
const sent = values?.[f.id]
|
|
if (sent === undefined || sent === null || sent === '') return false
|
|
return norm(detailValue(view, f.label), f.type) !== norm(sent, f.type)
|
|
}).map((f) => f.label)
|
|
}
|
|
|
|
/** Status badge tone, matched case-insensitively ("active" -> success). */
|
|
function statusTone(status) {
|
|
const key = Object.keys(EMPLOYEE_STATUS_TONE).find(
|
|
(k) => k.toLowerCase() === String(status).trim().toLowerCase(),
|
|
)
|
|
return key ? EMPLOYEE_STATUS_TONE[key] : 'neutral'
|
|
}
|