286 lines
7.7 KiB
JavaScript
286 lines
7.7 KiB
JavaScript
import { AppError } from '../lib/errors.js';
|
|
import { query } from './db.js';
|
|
|
|
function parseLimit(value, fallback = 20, max = 100) {
|
|
const parsed = Number.parseInt(`${value || fallback}`, 10);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
return Math.min(parsed, max);
|
|
}
|
|
|
|
function parseOffset(value) {
|
|
const parsed = Number.parseInt(`${value || 0}`, 10);
|
|
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
|
return parsed;
|
|
}
|
|
|
|
export async function listOrders({ tenantId, businessId, status, limit, offset }) {
|
|
const result = await query(
|
|
`
|
|
SELECT
|
|
o.id,
|
|
o.order_number AS "orderNumber",
|
|
o.title,
|
|
o.status,
|
|
o.service_type AS "serviceType",
|
|
o.starts_at AS "startsAt",
|
|
o.ends_at AS "endsAt",
|
|
o.location_name AS "locationName",
|
|
o.location_address AS "locationAddress",
|
|
o.created_at AS "createdAt",
|
|
b.id AS "businessId",
|
|
b.business_name AS "businessName",
|
|
v.id AS "vendorId",
|
|
v.company_name AS "vendorName",
|
|
COALESCE(COUNT(s.id), 0)::INTEGER AS "shiftCount",
|
|
COALESCE(SUM(s.required_workers), 0)::INTEGER AS "requiredWorkers",
|
|
COALESCE(SUM(s.assigned_workers), 0)::INTEGER AS "assignedWorkers"
|
|
FROM orders o
|
|
JOIN businesses b ON b.id = o.business_id
|
|
LEFT JOIN vendors v ON v.id = o.vendor_id
|
|
LEFT JOIN shifts s ON s.order_id = o.id
|
|
WHERE o.tenant_id = $1
|
|
AND ($2::uuid IS NULL OR o.business_id = $2::uuid)
|
|
AND ($3::text IS NULL OR o.status = $3::text)
|
|
GROUP BY o.id, b.id, v.id
|
|
ORDER BY o.created_at DESC
|
|
LIMIT $4 OFFSET $5
|
|
`,
|
|
[
|
|
tenantId,
|
|
businessId || null,
|
|
status || null,
|
|
parseLimit(limit),
|
|
parseOffset(offset),
|
|
]
|
|
);
|
|
|
|
return result.rows;
|
|
}
|
|
|
|
export async function getOrderDetail({ tenantId, orderId }) {
|
|
const orderResult = await query(
|
|
`
|
|
SELECT
|
|
o.id,
|
|
o.order_number AS "orderNumber",
|
|
o.title,
|
|
o.description,
|
|
o.status,
|
|
o.service_type AS "serviceType",
|
|
o.starts_at AS "startsAt",
|
|
o.ends_at AS "endsAt",
|
|
o.location_name AS "locationName",
|
|
o.location_address AS "locationAddress",
|
|
o.latitude,
|
|
o.longitude,
|
|
o.notes,
|
|
o.created_at AS "createdAt",
|
|
b.id AS "businessId",
|
|
b.business_name AS "businessName",
|
|
v.id AS "vendorId",
|
|
v.company_name AS "vendorName"
|
|
FROM orders o
|
|
JOIN businesses b ON b.id = o.business_id
|
|
LEFT JOIN vendors v ON v.id = o.vendor_id
|
|
WHERE o.tenant_id = $1
|
|
AND o.id = $2
|
|
`,
|
|
[tenantId, orderId]
|
|
);
|
|
|
|
if (orderResult.rowCount === 0) {
|
|
throw new AppError('NOT_FOUND', 'Order not found', 404, { tenantId, orderId });
|
|
}
|
|
|
|
const shiftsResult = await query(
|
|
`
|
|
SELECT
|
|
s.id,
|
|
s.shift_code AS "shiftCode",
|
|
s.title,
|
|
s.status,
|
|
s.starts_at AS "startsAt",
|
|
s.ends_at AS "endsAt",
|
|
s.timezone,
|
|
s.location_name AS "locationName",
|
|
s.location_address AS "locationAddress",
|
|
s.required_workers AS "requiredWorkers",
|
|
s.assigned_workers AS "assignedWorkers",
|
|
cp.id AS "clockPointId",
|
|
cp.label AS "clockPointLabel"
|
|
FROM shifts s
|
|
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
|
WHERE s.tenant_id = $1
|
|
AND s.order_id = $2
|
|
ORDER BY s.starts_at ASC
|
|
`,
|
|
[tenantId, orderId]
|
|
);
|
|
|
|
const shiftIds = shiftsResult.rows.map((row) => row.id);
|
|
let rolesByShiftId = new Map();
|
|
|
|
if (shiftIds.length > 0) {
|
|
const rolesResult = await query(
|
|
`
|
|
SELECT
|
|
sr.id,
|
|
sr.shift_id AS "shiftId",
|
|
sr.role_code AS "roleCode",
|
|
sr.role_name AS "roleName",
|
|
sr.workers_needed AS "workersNeeded",
|
|
sr.assigned_count AS "assignedCount",
|
|
sr.pay_rate_cents AS "payRateCents",
|
|
sr.bill_rate_cents AS "billRateCents"
|
|
FROM shift_roles sr
|
|
WHERE sr.shift_id = ANY($1::uuid[])
|
|
ORDER BY sr.role_name ASC
|
|
`,
|
|
[shiftIds]
|
|
);
|
|
rolesByShiftId = rolesResult.rows.reduce((map, row) => {
|
|
const list = map.get(row.shiftId) || [];
|
|
list.push(row);
|
|
map.set(row.shiftId, list);
|
|
return map;
|
|
}, new Map());
|
|
}
|
|
|
|
return {
|
|
...orderResult.rows[0],
|
|
shifts: shiftsResult.rows.map((shift) => ({
|
|
...shift,
|
|
roles: rolesByShiftId.get(shift.id) || [],
|
|
})),
|
|
};
|
|
}
|
|
|
|
export async function listFavoriteStaff({ tenantId, businessId, limit, offset }) {
|
|
const result = await query(
|
|
`
|
|
SELECT
|
|
sf.id AS "favoriteId",
|
|
sf.created_at AS "favoritedAt",
|
|
s.id AS "staffId",
|
|
s.full_name AS "fullName",
|
|
s.primary_role AS "primaryRole",
|
|
s.average_rating AS "averageRating",
|
|
s.rating_count AS "ratingCount",
|
|
s.status
|
|
FROM staff_favorites sf
|
|
JOIN staffs s ON s.id = sf.staff_id
|
|
WHERE sf.tenant_id = $1
|
|
AND sf.business_id = $2
|
|
ORDER BY sf.created_at DESC
|
|
LIMIT $3 OFFSET $4
|
|
`,
|
|
[tenantId, businessId, parseLimit(limit), parseOffset(offset)]
|
|
);
|
|
return result.rows;
|
|
}
|
|
|
|
export async function getStaffReviewSummary({ tenantId, staffId, limit }) {
|
|
const staffResult = await query(
|
|
`
|
|
SELECT
|
|
id AS "staffId",
|
|
full_name AS "fullName",
|
|
average_rating AS "averageRating",
|
|
rating_count AS "ratingCount",
|
|
primary_role AS "primaryRole",
|
|
status
|
|
FROM staffs
|
|
WHERE tenant_id = $1
|
|
AND id = $2
|
|
`,
|
|
[tenantId, staffId]
|
|
);
|
|
|
|
if (staffResult.rowCount === 0) {
|
|
throw new AppError('NOT_FOUND', 'Staff not found', 404, { tenantId, staffId });
|
|
}
|
|
|
|
const reviewsResult = await query(
|
|
`
|
|
SELECT
|
|
sr.id AS "reviewId",
|
|
sr.rating,
|
|
sr.review_text AS "reviewText",
|
|
sr.tags,
|
|
sr.created_at AS "createdAt",
|
|
b.id AS "businessId",
|
|
b.business_name AS "businessName",
|
|
sr.assignment_id AS "assignmentId"
|
|
FROM staff_reviews sr
|
|
JOIN businesses b ON b.id = sr.business_id
|
|
WHERE sr.tenant_id = $1
|
|
AND sr.staff_id = $2
|
|
ORDER BY sr.created_at DESC
|
|
LIMIT $3
|
|
`,
|
|
[tenantId, staffId, parseLimit(limit, 10, 50)]
|
|
);
|
|
|
|
return {
|
|
...staffResult.rows[0],
|
|
reviews: reviewsResult.rows,
|
|
};
|
|
}
|
|
|
|
export async function getAssignmentAttendance({ tenantId, assignmentId }) {
|
|
const assignmentResult = await query(
|
|
`
|
|
SELECT
|
|
a.id AS "assignmentId",
|
|
a.status,
|
|
a.shift_id AS "shiftId",
|
|
a.staff_id AS "staffId",
|
|
s.title AS "shiftTitle",
|
|
s.starts_at AS "shiftStartsAt",
|
|
s.ends_at AS "shiftEndsAt",
|
|
attendance_sessions.id AS "sessionId",
|
|
attendance_sessions.status AS "sessionStatus",
|
|
attendance_sessions.check_in_at AS "checkInAt",
|
|
attendance_sessions.check_out_at AS "checkOutAt",
|
|
attendance_sessions.worked_minutes AS "workedMinutes"
|
|
FROM assignments a
|
|
JOIN shifts s ON s.id = a.shift_id
|
|
LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id
|
|
WHERE a.id = $1
|
|
AND a.tenant_id = $2
|
|
`,
|
|
[assignmentId, tenantId]
|
|
);
|
|
|
|
if (assignmentResult.rowCount === 0) {
|
|
throw new AppError('NOT_FOUND', 'Assignment not found', 404, { tenantId, assignmentId });
|
|
}
|
|
|
|
const eventsResult = await query(
|
|
`
|
|
SELECT
|
|
id AS "attendanceEventId",
|
|
event_type AS "eventType",
|
|
source_type AS "sourceType",
|
|
source_reference AS "sourceReference",
|
|
nfc_tag_uid AS "nfcTagUid",
|
|
latitude,
|
|
longitude,
|
|
distance_to_clock_point_meters AS "distanceToClockPointMeters",
|
|
within_geofence AS "withinGeofence",
|
|
validation_status AS "validationStatus",
|
|
validation_reason AS "validationReason",
|
|
captured_at AS "capturedAt"
|
|
FROM attendance_events
|
|
WHERE assignment_id = $1
|
|
ORDER BY captured_at ASC
|
|
`,
|
|
[assignmentId]
|
|
);
|
|
|
|
return {
|
|
...assignmentResult.rows[0],
|
|
events: eventsResult.rows,
|
|
};
|
|
}
|