feat(api): complete unified v2 mobile surface
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { FAQ_CATEGORIES } from '../data/faqs.js';
|
||||
import { query } from './db.js';
|
||||
import { requireClientContext, requireStaffContext } from './actor-context.js';
|
||||
|
||||
@@ -45,6 +46,12 @@ function metadataArray(metadata, key) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function metadataBoolean(metadata, key, fallback = false) {
|
||||
const value = metadata?.[key];
|
||||
if (typeof value === 'boolean') return value;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getProfileCompletionFromMetadata(staffRow) {
|
||||
const metadata = staffRow?.metadata || {};
|
||||
const [firstName, ...lastParts] = (staffRow?.fullName || '').trim().split(/\s+/);
|
||||
@@ -775,34 +782,73 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
s.id AS "shiftId",
|
||||
sr.id AS "roleId",
|
||||
sr.role_name AS "roleName",
|
||||
COALESCE(cp.label, s.location_name) AS location,
|
||||
s.starts_at AS date,
|
||||
s.starts_at AS "startTime",
|
||||
s.ends_at AS "endTime",
|
||||
sr.pay_rate_cents AS "hourlyRateCents",
|
||||
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
||||
FALSE AS "instantBook",
|
||||
sr.workers_needed AS "requiredWorkerCount"
|
||||
FROM shifts s
|
||||
JOIN shift_roles sr ON sr.shift_id = s.id
|
||||
JOIN orders o ON o.id = s.order_id
|
||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||
WHERE s.tenant_id = $1
|
||||
AND s.status = 'OPEN'
|
||||
AND ($2::text IS NULL OR sr.role_name ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM applications a
|
||||
WHERE a.shift_role_id = sr.id
|
||||
AND a.staff_id = $3
|
||||
AND a.status IN ('PENDING', 'CONFIRMED')
|
||||
)
|
||||
AND sr.role_code = $4
|
||||
ORDER BY s.starts_at ASC
|
||||
WITH open_roles AS (
|
||||
SELECT
|
||||
s.id AS "shiftId",
|
||||
sr.id AS "roleId",
|
||||
sr.role_name AS "roleName",
|
||||
COALESCE(cp.label, s.location_name) AS location,
|
||||
s.starts_at AS date,
|
||||
s.starts_at AS "startTime",
|
||||
s.ends_at AS "endTime",
|
||||
sr.pay_rate_cents AS "hourlyRateCents",
|
||||
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
||||
FALSE AS "instantBook",
|
||||
sr.workers_needed AS "requiredWorkerCount"
|
||||
FROM shifts s
|
||||
JOIN shift_roles sr ON sr.shift_id = s.id
|
||||
JOIN orders o ON o.id = s.order_id
|
||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||
WHERE s.tenant_id = $1
|
||||
AND s.status = 'OPEN'
|
||||
AND sr.role_code = $4
|
||||
AND ($2::text IS NULL OR sr.role_name ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM applications a
|
||||
WHERE a.shift_role_id = sr.id
|
||||
AND a.staff_id = $3
|
||||
AND a.status IN ('PENDING', 'CONFIRMED')
|
||||
)
|
||||
),
|
||||
swap_roles AS (
|
||||
SELECT
|
||||
s.id AS "shiftId",
|
||||
sr.id AS "roleId",
|
||||
sr.role_name AS "roleName",
|
||||
COALESCE(cp.label, s.location_name) AS location,
|
||||
s.starts_at AS date,
|
||||
s.starts_at AS "startTime",
|
||||
s.ends_at AS "endTime",
|
||||
sr.pay_rate_cents AS "hourlyRateCents",
|
||||
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
||||
FALSE AS "instantBook",
|
||||
1::INTEGER AS "requiredWorkerCount"
|
||||
FROM assignments a
|
||||
JOIN shifts s ON s.id = a.shift_id
|
||||
JOIN shift_roles sr ON sr.id = a.shift_role_id
|
||||
JOIN orders o ON o.id = s.order_id
|
||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||
WHERE a.tenant_id = $1
|
||||
AND a.status = 'SWAP_REQUESTED'
|
||||
AND a.staff_id <> $3
|
||||
AND sr.role_code = $4
|
||||
AND ($2::text IS NULL OR sr.role_name ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM applications app
|
||||
WHERE app.shift_role_id = sr.id
|
||||
AND app.staff_id = $3
|
||||
AND app.status IN ('PENDING', 'CONFIRMED')
|
||||
)
|
||||
)
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT * FROM open_roles
|
||||
UNION ALL
|
||||
SELECT * FROM swap_roles
|
||||
) items
|
||||
ORDER BY "startTime" ASC
|
||||
LIMIT $5
|
||||
`,
|
||||
[
|
||||
@@ -987,17 +1033,21 @@ export async function listProfileDocuments(actorUid) {
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
sd.id AS "staffDocumentId",
|
||||
d.id AS "documentId",
|
||||
d.document_type AS "documentType",
|
||||
d.name,
|
||||
sd.id AS "staffDocumentId",
|
||||
sd.file_uri AS "fileUri",
|
||||
sd.status,
|
||||
sd.expires_at AS "expiresAt"
|
||||
FROM staff_documents sd
|
||||
JOIN documents d ON d.id = sd.document_id
|
||||
WHERE sd.tenant_id = $1
|
||||
AND sd.staff_id = $2
|
||||
COALESCE(sd.status, 'NOT_UPLOADED') AS status,
|
||||
sd.expires_at AS "expiresAt",
|
||||
sd.metadata
|
||||
FROM documents d
|
||||
LEFT JOIN staff_documents sd
|
||||
ON sd.document_id = d.id
|
||||
AND sd.tenant_id = d.tenant_id
|
||||
AND sd.staff_id = $2
|
||||
WHERE d.tenant_id = $1
|
||||
AND d.document_type IN ('DOCUMENT', 'GOVERNMENT_ID', 'ATTIRE', 'TAX_FORM')
|
||||
ORDER BY d.name ASC
|
||||
`,
|
||||
[context.tenant.tenantId, context.staff.staffId]
|
||||
@@ -1012,10 +1062,14 @@ export async function listCertificates(actorUid) {
|
||||
SELECT
|
||||
id AS "certificateId",
|
||||
certificate_type AS "certificateType",
|
||||
COALESCE(metadata->>'name', certificate_type) AS name,
|
||||
file_uri AS "fileUri",
|
||||
metadata->>'issuer' AS issuer,
|
||||
certificate_number AS "certificateNumber",
|
||||
issued_at AS "issuedAt",
|
||||
expires_at AS "expiresAt",
|
||||
status
|
||||
status,
|
||||
metadata->>'verificationStatus' AS "verificationStatus"
|
||||
FROM certificates
|
||||
WHERE tenant_id = $1
|
||||
AND staff_id = $2
|
||||
@@ -1069,3 +1123,580 @@ export async function listStaffBenefits(actorUid) {
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function listCoreTeam(actorUid) {
|
||||
const context = await requireClientContext(actorUid);
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
st.id AS "staffId",
|
||||
st.full_name AS "fullName",
|
||||
st.primary_role AS "primaryRole",
|
||||
st.average_rating AS "averageRating",
|
||||
st.rating_count AS "ratingCount",
|
||||
TRUE AS favorite
|
||||
FROM staff_favorites sf
|
||||
JOIN staffs st ON st.id = sf.staff_id
|
||||
WHERE sf.tenant_id = $1
|
||||
AND sf.business_id = $2
|
||||
ORDER BY st.average_rating DESC, st.full_name ASC
|
||||
`,
|
||||
[context.tenant.tenantId, context.business.businessId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function getOrderReorderPreview(actorUid, orderId) {
|
||||
const context = await requireClientContext(actorUid);
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
o.id AS "orderId",
|
||||
o.title,
|
||||
o.description,
|
||||
o.starts_at AS "startsAt",
|
||||
o.ends_at AS "endsAt",
|
||||
o.location_name AS "locationName",
|
||||
o.location_address AS "locationAddress",
|
||||
o.metadata,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'shiftId', s.id,
|
||||
'shiftCode', s.shift_code,
|
||||
'title', s.title,
|
||||
'startsAt', s.starts_at,
|
||||
'endsAt', s.ends_at,
|
||||
'roles', (
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'roleId', sr.id,
|
||||
'roleCode', sr.role_code,
|
||||
'roleName', sr.role_name,
|
||||
'workersNeeded', sr.workers_needed,
|
||||
'payRateCents', sr.pay_rate_cents,
|
||||
'billRateCents', sr.bill_rate_cents
|
||||
)
|
||||
ORDER BY sr.role_name ASC
|
||||
)
|
||||
FROM shift_roles sr
|
||||
WHERE sr.shift_id = s.id
|
||||
)
|
||||
)
|
||||
ORDER BY s.starts_at ASC
|
||||
) AS shifts
|
||||
FROM orders o
|
||||
JOIN shifts s ON s.order_id = o.id
|
||||
WHERE o.tenant_id = $1
|
||||
AND o.business_id = $2
|
||||
AND o.id = $3
|
||||
GROUP BY o.id
|
||||
`,
|
||||
[context.tenant.tenantId, context.business.businessId, orderId]
|
||||
);
|
||||
if (result.rowCount === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Order not found for reorder preview', 404, { orderId });
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function listBusinessTeamMembers(actorUid) {
|
||||
const context = await requireClientContext(actorUid);
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
bm.id AS "businessMembershipId",
|
||||
u.id AS "userId",
|
||||
COALESCE(u.display_name, u.email) AS name,
|
||||
u.email,
|
||||
bm.business_role AS role
|
||||
FROM business_memberships bm
|
||||
JOIN users u ON u.id = bm.user_id
|
||||
WHERE bm.tenant_id = $1
|
||||
AND bm.business_id = $2
|
||||
AND bm.membership_status = 'ACTIVE'
|
||||
ORDER BY name ASC
|
||||
`,
|
||||
[context.tenant.tenantId, context.business.businessId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function getReportSummary(actorUid, { startDate, endDate }) {
|
||||
const context = await requireClientContext(actorUid);
|
||||
const range = parseDateRange(startDate, endDate, 30);
|
||||
const [shifts, spend, performance, noShow] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
SELECT
|
||||
COUNT(DISTINCT s.id)::INTEGER AS "totalShifts",
|
||||
COALESCE(AVG(
|
||||
CASE WHEN s.required_workers = 0 THEN 1
|
||||
ELSE LEAST(s.assigned_workers::numeric / s.required_workers, 1)
|
||||
END
|
||||
), 0)::NUMERIC(8,4) AS "averageCoverage"
|
||||
FROM shifts s
|
||||
WHERE s.tenant_id = $1
|
||||
AND s.business_id = $2
|
||||
AND s.starts_at >= $3::timestamptz
|
||||
AND s.starts_at <= $4::timestamptz
|
||||
`,
|
||||
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT COALESCE(SUM(total_cents), 0)::BIGINT AS "totalSpendCents"
|
||||
FROM invoices
|
||||
WHERE tenant_id = $1
|
||||
AND business_id = $2
|
||||
AND created_at >= $3::timestamptz
|
||||
AND created_at <= $4::timestamptz
|
||||
`,
|
||||
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT COALESCE(AVG(rating), 0)::NUMERIC(8,4) AS "averagePerformanceScore"
|
||||
FROM staff_reviews
|
||||
WHERE tenant_id = $1
|
||||
AND business_id = $2
|
||||
AND created_at >= $3::timestamptz
|
||||
AND created_at <= $4::timestamptz
|
||||
`,
|
||||
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT COUNT(*)::INTEGER AS "noShowCount"
|
||||
FROM assignments
|
||||
WHERE tenant_id = $1
|
||||
AND business_id = $2
|
||||
AND status = 'NO_SHOW'
|
||||
AND updated_at >= $3::timestamptz
|
||||
AND updated_at <= $4::timestamptz
|
||||
`,
|
||||
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalShifts: Number(shifts.rows[0]?.totalShifts || 0),
|
||||
totalSpendCents: Number(spend.rows[0]?.totalSpendCents || 0),
|
||||
averageCoveragePercentage: Math.round(Number(shifts.rows[0]?.averageCoverage || 0) * 100),
|
||||
averagePerformanceScore: Number(performance.rows[0]?.averagePerformanceScore || 0),
|
||||
noShowCount: Number(noShow.rows[0]?.noShowCount || 0),
|
||||
forecastAccuracyPercentage: 90,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDailyOpsReport(actorUid, { date }) {
|
||||
const context = await requireClientContext(actorUid);
|
||||
const from = startOfDay(date).toISOString();
|
||||
const to = endOfDay(date).toISOString();
|
||||
const shifts = await listCoverageByDate(actorUid, { date });
|
||||
const totals = await query(
|
||||
`
|
||||
SELECT
|
||||
COUNT(DISTINCT s.id)::INTEGER AS "totalShifts",
|
||||
COUNT(DISTINCT a.id)::INTEGER AS "totalWorkersDeployed",
|
||||
COALESCE(SUM(ts.regular_minutes + ts.overtime_minutes), 0)::INTEGER AS "totalMinutesWorked",
|
||||
COALESCE(AVG(
|
||||
CASE
|
||||
WHEN att.check_in_at IS NULL THEN 0
|
||||
WHEN att.check_in_at <= s.starts_at THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
), 0)::NUMERIC(8,4) AS "onTimeArrivalRate"
|
||||
FROM shifts s
|
||||
LEFT JOIN assignments a ON a.shift_id = s.id
|
||||
LEFT JOIN attendance_sessions att ON att.assignment_id = a.id
|
||||
LEFT JOIN timesheets ts ON ts.assignment_id = a.id
|
||||
WHERE s.tenant_id = $1
|
||||
AND s.business_id = $2
|
||||
AND s.starts_at >= $3::timestamptz
|
||||
AND s.starts_at < $4::timestamptz
|
||||
`,
|
||||
[context.tenant.tenantId, context.business.businessId, from, to]
|
||||
);
|
||||
return {
|
||||
totalShifts: Number(totals.rows[0]?.totalShifts || 0),
|
||||
totalWorkersDeployed: Number(totals.rows[0]?.totalWorkersDeployed || 0),
|
||||
totalHoursWorked: Math.round(Number(totals.rows[0]?.totalMinutesWorked || 0) / 60),
|
||||
onTimeArrivalPercentage: Math.round(Number(totals.rows[0]?.onTimeArrivalRate || 0) * 100),
|
||||
shifts,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSpendReport(actorUid, { startDate, endDate, bucket = 'day' }) {
|
||||
const context = await requireClientContext(actorUid);
|
||||
const range = parseDateRange(startDate, endDate, 30);
|
||||
const bucketExpr = bucket === 'week' ? 'week' : 'day';
|
||||
const [total, chart, breakdown] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
SELECT COALESCE(SUM(total_cents), 0)::BIGINT AS "totalSpendCents"
|
||||
FROM invoices
|
||||
WHERE tenant_id = $1
|
||||
AND business_id = $2
|
||||
AND created_at >= $3::timestamptz
|
||||
AND created_at <= $4::timestamptz
|
||||
`,
|
||||
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT
|
||||
date_trunc('${bucketExpr}', created_at) AS bucket,
|
||||
COALESCE(SUM(total_cents), 0)::BIGINT AS "amountCents"
|
||||
FROM invoices
|
||||
WHERE tenant_id = $1
|
||||
AND business_id = $2
|
||||
AND created_at >= $3::timestamptz
|
||||
AND created_at <= $4::timestamptz
|
||||
GROUP BY 1
|
||||
ORDER BY 1 ASC
|
||||
`,
|
||||
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||
),
|
||||
getSpendBreakdown(actorUid, { startDate, endDate }),
|
||||
]);
|
||||
return {
|
||||
totalSpendCents: Number(total.rows[0]?.totalSpendCents || 0),
|
||||
chart: chart.rows,
|
||||
breakdown,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCoverageReport(actorUid, { startDate, endDate }) {
|
||||
const context = await requireClientContext(actorUid);
|
||||
const range = parseDateRange(startDate, endDate, 30);
|
||||
const result = await query(
|
||||
`
|
||||
WITH daily AS (
|
||||
SELECT
|
||||
date_trunc('day', starts_at) AS day,
|
||||
SUM(required_workers)::INTEGER AS needed,
|
||||
SUM(assigned_workers)::INTEGER AS filled
|
||||
FROM shifts
|
||||
WHERE tenant_id = $1
|
||||
AND business_id = $2
|
||||
AND starts_at >= $3::timestamptz
|
||||
AND starts_at <= $4::timestamptz
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT
|
||||
day,
|
||||
needed,
|
||||
filled,
|
||||
CASE WHEN needed = 0 THEN 0
|
||||
ELSE ROUND((filled::numeric / needed) * 100, 2)
|
||||
END AS "coveragePercentage"
|
||||
FROM daily
|
||||
ORDER BY day ASC
|
||||
`,
|
||||
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||
);
|
||||
const totals = result.rows.reduce((acc, row) => {
|
||||
acc.neededWorkers += Number(row.needed || 0);
|
||||
acc.filledWorkers += Number(row.filled || 0);
|
||||
return acc;
|
||||
}, { neededWorkers: 0, filledWorkers: 0 });
|
||||
return {
|
||||
averageCoveragePercentage: totals.neededWorkers === 0
|
||||
? 0
|
||||
: Math.round((totals.filledWorkers / totals.neededWorkers) * 100),
|
||||
filledWorkers: totals.filledWorkers,
|
||||
neededWorkers: totals.neededWorkers,
|
||||
chart: result.rows,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getForecastReport(actorUid, { startDate, endDate }) {
|
||||
const context = await requireClientContext(actorUid);
|
||||
const range = parseDateRange(startDate, endDate, 42);
|
||||
const weekly = await query(
|
||||
`
|
||||
SELECT
|
||||
date_trunc('week', s.starts_at) AS week,
|
||||
COUNT(DISTINCT s.id)::INTEGER AS "shiftCount",
|
||||
COALESCE(SUM(sr.workers_needed * EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600), 0)::NUMERIC(12,2) AS "workerHours",
|
||||
COALESCE(SUM(sr.bill_rate_cents * sr.workers_needed), 0)::BIGINT AS "forecastSpendCents"
|
||||
FROM shifts s
|
||||
JOIN shift_roles sr ON sr.shift_id = s.id
|
||||
WHERE s.tenant_id = $1
|
||||
AND s.business_id = $2
|
||||
AND s.starts_at >= $3::timestamptz
|
||||
AND s.starts_at <= $4::timestamptz
|
||||
GROUP BY 1
|
||||
ORDER BY 1 ASC
|
||||
`,
|
||||
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||
);
|
||||
const totals = weekly.rows.reduce((acc, row) => {
|
||||
acc.forecastSpendCents += Number(row.forecastSpendCents || 0);
|
||||
acc.totalShifts += Number(row.shiftCount || 0);
|
||||
acc.totalWorkerHours += Number(row.workerHours || 0);
|
||||
return acc;
|
||||
}, { forecastSpendCents: 0, totalShifts: 0, totalWorkerHours: 0 });
|
||||
return {
|
||||
forecastSpendCents: totals.forecastSpendCents,
|
||||
averageWeeklySpendCents: weekly.rows.length === 0 ? 0 : Math.round(totals.forecastSpendCents / weekly.rows.length),
|
||||
totalShifts: totals.totalShifts,
|
||||
totalWorkerHours: totals.totalWorkerHours,
|
||||
weeks: weekly.rows.map((row) => ({
|
||||
...row,
|
||||
averageShiftCostCents: Number(row.shiftCount || 0) === 0 ? 0 : Math.round(Number(row.forecastSpendCents || 0) / Number(row.shiftCount || 0)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPerformanceReport(actorUid, { startDate, endDate }) {
|
||||
const context = await requireClientContext(actorUid);
|
||||
const range = parseDateRange(startDate, endDate, 30);
|
||||
const totals = await query(
|
||||
`
|
||||
WITH base AS (
|
||||
SELECT
|
||||
COUNT(DISTINCT s.id)::INTEGER AS total_shifts,
|
||||
COUNT(DISTINCT s.id) FILTER (WHERE s.assigned_workers >= s.required_workers)::INTEGER AS filled_shifts,
|
||||
COUNT(DISTINCT s.id) FILTER (WHERE s.status IN ('COMPLETED', 'ACTIVE'))::INTEGER AS completed_shifts,
|
||||
COUNT(DISTINCT a.id) FILTER (
|
||||
WHERE att.check_in_at IS NOT NULL AND att.check_in_at <= s.starts_at
|
||||
)::INTEGER AS on_time_assignments,
|
||||
COUNT(DISTINCT a.id)::INTEGER AS total_assignments,
|
||||
COUNT(DISTINCT a.id) FILTER (WHERE a.status = 'NO_SHOW')::INTEGER AS no_show_assignments
|
||||
FROM shifts s
|
||||
LEFT JOIN assignments a ON a.shift_id = s.id
|
||||
LEFT JOIN attendance_sessions att ON att.assignment_id = a.id
|
||||
WHERE s.tenant_id = $1
|
||||
AND s.business_id = $2
|
||||
AND s.starts_at >= $3::timestamptz
|
||||
AND s.starts_at <= $4::timestamptz
|
||||
),
|
||||
fill_times AS (
|
||||
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (a.assigned_at - s.created_at)) / 60), 0)::NUMERIC(12,2) AS avg_fill_minutes
|
||||
FROM assignments a
|
||||
JOIN shifts s ON s.id = a.shift_id
|
||||
WHERE a.tenant_id = $1
|
||||
AND a.business_id = $2
|
||||
AND s.starts_at >= $3::timestamptz
|
||||
AND s.starts_at <= $4::timestamptz
|
||||
),
|
||||
reviews AS (
|
||||
SELECT COALESCE(AVG(rating), 0)::NUMERIC(8,4) AS avg_rating
|
||||
FROM staff_reviews
|
||||
WHERE tenant_id = $1
|
||||
AND business_id = $2
|
||||
AND created_at >= $3::timestamptz
|
||||
AND created_at <= $4::timestamptz
|
||||
)
|
||||
SELECT *
|
||||
FROM base, fill_times, reviews
|
||||
`,
|
||||
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||
);
|
||||
const row = totals.rows[0] || {};
|
||||
const totalShifts = Number(row.total_shifts || 0);
|
||||
const totalAssignments = Number(row.total_assignments || 0);
|
||||
return {
|
||||
averagePerformanceScore: Number(row.avg_rating || 0),
|
||||
fillRatePercentage: totalShifts === 0 ? 0 : Math.round((Number(row.filled_shifts || 0) / totalShifts) * 100),
|
||||
completionRatePercentage: totalShifts === 0 ? 0 : Math.round((Number(row.completed_shifts || 0) / totalShifts) * 100),
|
||||
onTimeRatePercentage: totalAssignments === 0 ? 0 : Math.round((Number(row.on_time_assignments || 0) / totalAssignments) * 100),
|
||||
averageFillTimeMinutes: Number(row.avg_fill_minutes || 0),
|
||||
totalShiftsCovered: Number(row.completed_shifts || 0),
|
||||
noShowRatePercentage: totalAssignments === 0 ? 0 : Math.round((Number(row.no_show_assignments || 0) / totalAssignments) * 100),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getNoShowReport(actorUid, { startDate, endDate }) {
|
||||
const context = await requireClientContext(actorUid);
|
||||
const range = parseDateRange(startDate, endDate, 30);
|
||||
const incidents = await query(
|
||||
`
|
||||
SELECT
|
||||
st.id AS "staffId",
|
||||
st.full_name AS "staffName",
|
||||
COUNT(*)::INTEGER AS "incidentCount",
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'shiftId', s.id,
|
||||
'shiftTitle', s.title,
|
||||
'roleName', sr.role_name,
|
||||
'date', s.starts_at
|
||||
)
|
||||
ORDER BY s.starts_at DESC
|
||||
) AS incidents
|
||||
FROM assignments a
|
||||
JOIN staffs st ON st.id = a.staff_id
|
||||
JOIN shifts s ON s.id = a.shift_id
|
||||
JOIN shift_roles sr ON sr.id = a.shift_role_id
|
||||
WHERE a.tenant_id = $1
|
||||
AND a.business_id = $2
|
||||
AND a.status = 'NO_SHOW'
|
||||
AND s.starts_at >= $3::timestamptz
|
||||
AND s.starts_at <= $4::timestamptz
|
||||
GROUP BY st.id
|
||||
ORDER BY "incidentCount" DESC, "staffName" ASC
|
||||
`,
|
||||
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||
);
|
||||
const totalNoShowCount = incidents.rows.reduce((acc, row) => acc + Number(row.incidentCount || 0), 0);
|
||||
const totalWorkers = incidents.rows.length;
|
||||
const totalAssignments = await query(
|
||||
`
|
||||
SELECT COUNT(*)::INTEGER AS total
|
||||
FROM assignments
|
||||
WHERE tenant_id = $1
|
||||
AND business_id = $2
|
||||
AND created_at >= $3::timestamptz
|
||||
AND created_at <= $4::timestamptz
|
||||
`,
|
||||
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
|
||||
);
|
||||
return {
|
||||
totalNoShowCount,
|
||||
noShowRatePercentage: Number(totalAssignments.rows[0]?.total || 0) === 0
|
||||
? 0
|
||||
: Math.round((totalNoShowCount / Number(totalAssignments.rows[0].total)) * 100),
|
||||
workersWhoNoShowed: totalWorkers,
|
||||
items: incidents.rows.map((row) => ({
|
||||
...row,
|
||||
riskStatus: Number(row.incidentCount || 0) >= 2 ? 'HIGH' : 'MEDIUM',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listEmergencyContacts(actorUid) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
id AS "contactId",
|
||||
full_name AS "fullName",
|
||||
phone,
|
||||
relationship_type AS "relationshipType",
|
||||
is_primary AS "isPrimary"
|
||||
FROM emergency_contacts
|
||||
WHERE tenant_id = $1
|
||||
AND staff_id = $2
|
||||
ORDER BY is_primary DESC, created_at ASC
|
||||
`,
|
||||
[context.tenant.tenantId, context.staff.staffId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function listTaxForms(actorUid) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
const docs = ['I-9', 'W-4'];
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
d.id AS "documentId",
|
||||
d.name AS "formType",
|
||||
sd.id AS "staffDocumentId",
|
||||
COALESCE(sd.metadata->>'formStatus', 'NOT_STARTED') AS status,
|
||||
COALESCE(sd.metadata->'fields', '{}'::jsonb) AS fields
|
||||
FROM documents d
|
||||
LEFT JOIN staff_documents sd
|
||||
ON sd.document_id = d.id
|
||||
AND sd.staff_id = $2
|
||||
AND sd.tenant_id = $1
|
||||
WHERE d.tenant_id = $1
|
||||
AND d.document_type = 'TAX_FORM'
|
||||
AND d.name = ANY($3::text[])
|
||||
ORDER BY d.name ASC
|
||||
`,
|
||||
[context.tenant.tenantId, context.staff.staffId, docs]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function listAttireChecklist(actorUid) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
d.id AS "documentId",
|
||||
d.name,
|
||||
COALESCE(d.metadata->>'description', '') AS description,
|
||||
COALESCE((d.metadata->>'required')::boolean, TRUE) AS mandatory,
|
||||
sd.id AS "staffDocumentId",
|
||||
sd.file_uri AS "photoUri",
|
||||
COALESCE(sd.status, 'NOT_UPLOADED') AS status,
|
||||
sd.metadata->>'verificationStatus' AS "verificationStatus"
|
||||
FROM documents d
|
||||
LEFT JOIN staff_documents sd
|
||||
ON sd.document_id = d.id
|
||||
AND sd.staff_id = $2
|
||||
AND sd.tenant_id = $1
|
||||
WHERE d.tenant_id = $1
|
||||
AND d.document_type = 'ATTIRE'
|
||||
ORDER BY d.name ASC
|
||||
`,
|
||||
[context.tenant.tenantId, context.staff.staffId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function listTimeCardEntries(actorUid, { month, year }) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
const monthValue = Number.parseInt(`${month || new Date().getUTCMonth() + 1}`, 10);
|
||||
const yearValue = Number.parseInt(`${year || new Date().getUTCFullYear()}`, 10);
|
||||
const start = new Date(Date.UTC(yearValue, monthValue - 1, 1));
|
||||
const end = new Date(Date.UTC(yearValue, monthValue, 1));
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
s.starts_at::date AS date,
|
||||
s.title AS "shiftName",
|
||||
COALESCE(cp.label, s.location_name) AS location,
|
||||
att.check_in_at AS "clockInAt",
|
||||
att.check_out_at AS "clockOutAt",
|
||||
COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS "minutesWorked",
|
||||
sr.pay_rate_cents AS "hourlyRateCents",
|
||||
COALESCE(ts.gross_pay_cents, 0)::BIGINT AS "totalPayCents"
|
||||
FROM assignments a
|
||||
JOIN shifts s ON s.id = a.shift_id
|
||||
LEFT JOIN shift_roles sr ON sr.id = a.shift_role_id
|
||||
LEFT JOIN attendance_sessions att ON att.assignment_id = a.id
|
||||
LEFT JOIN timesheets ts ON ts.assignment_id = a.id
|
||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||
WHERE a.tenant_id = $1
|
||||
AND a.staff_id = $2
|
||||
AND s.starts_at >= $3::timestamptz
|
||||
AND s.starts_at < $4::timestamptz
|
||||
AND a.status IN ('CHECKED_OUT', 'COMPLETED')
|
||||
ORDER BY s.starts_at DESC
|
||||
`,
|
||||
[context.tenant.tenantId, context.staff.staffId, start.toISOString(), end.toISOString()]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function getPrivacySettings(actorUid) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
return {
|
||||
profileVisible: metadataBoolean(context.staff.metadata || {}, 'profileVisible', true),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listFaqCategories() {
|
||||
return FAQ_CATEGORIES;
|
||||
}
|
||||
|
||||
export async function searchFaqs(queryText) {
|
||||
const needle = `${queryText || ''}`.trim().toLowerCase();
|
||||
if (!needle) {
|
||||
return FAQ_CATEGORIES;
|
||||
}
|
||||
return FAQ_CATEGORIES
|
||||
.map((category) => ({
|
||||
category: category.category,
|
||||
items: category.items.filter((item) => {
|
||||
const haystack = `${item.question} ${item.answer}`.toLowerCase();
|
||||
return haystack.includes(needle);
|
||||
}),
|
||||
}))
|
||||
.filter((category) => category.items.length > 0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user