asset-management/src/pages/employees/EmployeeDetails.jsx

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'
}