fix: correct access dashboard status counts

This commit is contained in:
unknown
2026-07-20 17:24:35 +09:00
parent 6180161225
commit 104d2f531a
3 changed files with 41 additions and 11 deletions

View File

@@ -6,9 +6,26 @@ import {
searchApprovedForCheckIn, searchApprovedForCheckIn,
} from '../api'; } from '../api';
import { AccessRecord, VisitRequestView } from '../types'; import { AccessRecord, VisitRequestView } from '../types';
import { formatShort } from '../status'; import { STATUS_CLASS, STATUS_LABEL, formatShort } from '../status';
import { VisitRequestDetailDialog } from '../components/VisitRequestDetailDialog'; import { VisitRequestDetailDialog } from '../components/VisitRequestDetailDialog';
const hasEnteredToday = (r: AccessRecord) => Boolean(r.checkInAt);
const hasExitedToday = (r: AccessRecord) => Boolean(r.checkOutAt);
function accessBadge(record: AccessRecord): { className: string; label: string } {
if (record.inside) {
return { className: 'green', label: '재실중' };
}
if (hasExitedToday(record)) {
return { className: 'gray', label: '퇴실' };
}
const status = record.status ?? 'APPROVED';
if (status === 'APPROVED') {
return { className: 'amber', label: '입장대기' };
}
return { className: STATUS_CLASS[status], label: STATUS_LABEL[status] };
}
/** /**
* Staff access console (login: 담당자/보안/관리자). Name search → force check-in/out, * Staff access console (login: 담당자/보안/관리자). Name search → force check-in/out,
* plus today's full access log (신청 입/퇴장 + 실제 입/퇴장, 퇴실자 포함). * plus today's full access log (신청 입/퇴장 + 실제 입/퇴장, 퇴실자 포함).
@@ -36,7 +53,7 @@ export const AccessConsolePage: React.FC = () => {
const insideIds = new Set(records.filter((r) => r.inside).map((r) => r.visitRequestId)); const insideIds = new Set(records.filter((r) => r.inside).map((r) => r.visitRequestId));
// Entered and already left today → no re-entry allowed. // Entered and already left today → no re-entry allowed.
const exitedIds = new Set(records.filter((r) => !r.inside).map((r) => r.visitRequestId)); const exitedIds = new Set(records.filter(hasExitedToday).map((r) => r.visitRequestId));
const wrap = async (fn: () => Promise<void>) => { const wrap = async (fn: () => Promise<void>) => {
setError(null); setError(null);
@@ -184,12 +201,13 @@ export const AccessConsolePage: React.FC = () => {
<td>{formatShort(r.checkInAt)}</td> <td>{formatShort(r.checkInAt)}</td>
<td>{formatShort(r.checkOutAt)}</td> <td>{formatShort(r.checkOutAt)}</td>
<td> <td>
<span className={`badge badge-${r.inside ? 'green' : 'gray'}`}> {(() => {
{r.inside ? '재실중' : '퇴실'} const badge = accessBadge(r);
</span> return <span className={`badge badge-${badge.className}`}>{badge.label}</span>;
})()}
</td> </td>
<td> <td>
{r.inside && ( {r.inside && hasEnteredToday(r) && (
<button <button
className="btn-danger" className="btn-danger"
disabled={busy} disabled={busy}

View File

@@ -213,6 +213,7 @@ export interface AccessRecord {
zoneName?: string; zoneName?: string;
visitFrom: string; visitFrom: string;
visitTo: string; visitTo: string;
status?: VisitStatus;
checkInAt?: string; checkInAt?: string;
checkOutAt?: string; checkOutAt?: string;
inside: boolean; inside: boolean;

View File

@@ -55,6 +55,7 @@ interface AccessRow {
host_name: string; host_name: string;
visit_from: string | Date; visit_from: string | Date;
visit_to: string | Date; visit_to: string | Date;
status: string;
check_in_at: string | Date | null; check_in_at: string | Date | null;
check_out_at: string | Date | null; check_out_at: string | Date | null;
} }
@@ -745,10 +746,13 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout
` `
SELECT SELECT
vr.id AS visit_request_id, v.name AS visitor_name, v.company, vr.zone_name, h.full_name AS host_name, vr.id AS visit_request_id, v.name AS visitor_name, v.company, vr.zone_name, h.full_name AS host_name,
vr.visit_from, vr.visit_to, vr.visit_from, vr.visit_to, vr.status,
min(ae.event_at) FILTER (WHERE ae.direction = 'IN') AS check_in_at, min(ae.event_at) FILTER (WHERE ae.direction = 'IN') AS check_in_at,
max(ae.event_at) FILTER (WHERE ae.direction = 'OUT') AS check_out_at, max(ae.event_at) FILTER (WHERE ae.direction = 'OUT') AS check_out_at,
(max(ae.event_at) FILTER (WHERE ae.direction = 'IN')) > COALESCE(max(ae.event_at) FILTER (WHERE ae.direction = 'OUT'), 'epoch') AS inside COALESCE(
(max(ae.event_at) FILTER (WHERE ae.direction = 'IN')) > COALESCE(max(ae.event_at) FILTER (WHERE ae.direction = 'OUT'), 'epoch'),
false
) AS inside
FROM visit_requests vr FROM visit_requests vr
JOIN visitors v ON v.id = vr.visitor_id JOIN visitors v ON v.id = vr.visitor_id
JOIN users h ON h.id = vr.host_id JOIN users h ON h.id = vr.host_id
@@ -765,9 +769,10 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout
zoneName: row.zone_name ?? undefined, zoneName: row.zone_name ?? undefined,
visitFrom: toIso(row.visit_from)!, visitFrom: toIso(row.visit_from)!,
visitTo: toIso(row.visit_to)!, visitTo: toIso(row.visit_to)!,
status: row.status,
checkInAt: toIso(row.check_in_at), checkInAt: toIso(row.check_in_at),
checkOutAt: toIso(row.check_out_at), checkOutAt: toIso(row.check_out_at),
inside: row.inside, inside: Boolean(row.inside),
}))); })));
})); }));
@@ -776,8 +781,14 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout
const result = await dbQuery<{ today_visits: string; pending: string; approved: string; total: string }>( const result = await dbQuery<{ today_visits: string; pending: string; approved: string; total: string }>(
` `
SELECT SELECT
count(*) FILTER (WHERE visit_from::date = now()::date) AS today_visits, count(*) FILTER (
count(*) FILTER (WHERE status = 'PENDING') AS pending, WHERE visit_from::date = now()::date
AND status IN ('PENDING', 'APPROVED')
) AS today_visits,
count(*) FILTER (
WHERE status = 'PENDING'
AND visit_to >= now()
) AS pending,
count(*) FILTER (WHERE status = 'APPROVED') AS approved, count(*) FILTER (WHERE status = 'APPROVED') AS approved,
count(*) AS total count(*) AS total
FROM visit_requests FROM visit_requests