Initial commit - Route Commerce platform
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
function rpcBody(body: Record<string, unknown>) {
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TimeTrackingSession = {
|
||||
worker_id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
lang: string;
|
||||
session_id: string;
|
||||
brand_id: string;
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
// ── Cookie helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const SESSION_COOKIE = "time_tracking_session";
|
||||
const COOKIE_MAX_AGE = 12 * 60 * 60; // 12 hours in seconds
|
||||
|
||||
function sessionCookie(session: TimeTrackingSession) {
|
||||
return `${session.worker_id}|${session.session_id}|${session.expires_at}`;
|
||||
}
|
||||
|
||||
function parseSessionCookie(cookie: string): TimeTrackingSession | null {
|
||||
const parts = cookie.split("|");
|
||||
if (parts.length !== 3) return null;
|
||||
const [worker_id, session_id, expires_at] = parts;
|
||||
if (new Date(expires_at) < new Date()) return null;
|
||||
return {
|
||||
worker_id,
|
||||
name: "",
|
||||
role: "worker",
|
||||
lang: "en",
|
||||
session_id,
|
||||
brand_id: "",
|
||||
expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Verify PIN ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function verifyTimeTrackingPin(
|
||||
brandId: string,
|
||||
pin: string
|
||||
): Promise<{ success: boolean; session?: TimeTrackingSession; error?: string }> {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/verify_time_tracking_pin`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: supabaseKey,
|
||||
Authorization: `Bearer ${supabaseKey}`,
|
||||
},
|
||||
body: rpcBody({ p_brand_id: brandId, p_pin: pin }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Invalid PIN" };
|
||||
}
|
||||
|
||||
const session: TimeTrackingSession = {
|
||||
worker_id: data.worker_id,
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
lang: data.lang,
|
||||
session_id: data.session_id,
|
||||
brand_id: data.brand_id,
|
||||
expires_at: data.expires_at,
|
||||
};
|
||||
|
||||
// Set HTTP-only cookie
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE, sessionCookie(session), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return { success: true, session };
|
||||
}
|
||||
|
||||
// ── Clock In ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function clockInWorker(
|
||||
brandId: string,
|
||||
taskId?: string,
|
||||
taskName = "General Labor"
|
||||
): Promise<{ success: boolean; log_id?: string; clock_in?: string; error?: string }> {
|
||||
const session = await getTimeTrackingSession();
|
||||
if (!session) return { success: false, error: "Not logged in" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/clock_in_worker`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: supabaseKey,
|
||||
Authorization: `Bearer ${supabaseKey}`,
|
||||
},
|
||||
body: rpcBody({
|
||||
p_brand_id: brandId,
|
||||
p_worker_id: session.worker_id,
|
||||
p_task_id: taskId ?? null,
|
||||
p_task_name: taskName,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true, log_id: data.log_id, clock_in: data.clock_in };
|
||||
}
|
||||
|
||||
// ── Clock Out ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function clockOutWorker(
|
||||
lunchMinutes = 0,
|
||||
notes?: string
|
||||
): Promise<{ success: boolean; log_id?: string; clock_out?: string; total_minutes?: number; error?: string }> {
|
||||
const session = await getTimeTrackingSession();
|
||||
if (!session) return { success: false, error: "Not logged in" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/clock_out_worker`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: supabaseKey,
|
||||
Authorization: `Bearer ${supabaseKey}`,
|
||||
},
|
||||
body: rpcBody({
|
||||
p_worker_id: session.worker_id,
|
||||
p_lunch_minutes: lunchMinutes,
|
||||
p_notes: notes ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return {
|
||||
success: true,
|
||||
log_id: data.log_id,
|
||||
clock_out: data.clock_out,
|
||||
total_minutes: data.total_minutes,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Get Open Clock In ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function getOpenClockIn(
|
||||
brandId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
open?: boolean;
|
||||
log_id?: string;
|
||||
task_name?: string;
|
||||
clock_in?: string;
|
||||
elapsed_minutes?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const session = await getTimeTrackingSession();
|
||||
if (!session) return { success: false, error: "Not logged in" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_open_clock_in`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: supabaseKey,
|
||||
Authorization: `Bearer ${supabaseKey}`,
|
||||
},
|
||||
body: rpcBody({ p_worker_id: session.worker_id }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return {
|
||||
success: true,
|
||||
open: data.open,
|
||||
log_id: data.log_id,
|
||||
task_name: data.task_name,
|
||||
clock_in: data.clock_in,
|
||||
elapsed_minutes: data.elapsed_minutes,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Logout ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function logoutTimeTracking(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(SESSION_COOKIE);
|
||||
}
|
||||
|
||||
// ── Get Current Session ────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTimeTrackingSession(): Promise<TimeTrackingSession | null> {
|
||||
const cookieStore = await cookies();
|
||||
const cookie = cookieStore.get(SESSION_COOKIE);
|
||||
if (!cookie) return null;
|
||||
return parseSessionCookie(cookie.value);
|
||||
}
|
||||
|
||||
// ── Get Tasks (for task picker) ─────────────────────────────────────────────────
|
||||
|
||||
export type TimeTaskField = {
|
||||
id: string;
|
||||
name: string;
|
||||
name_es: string | null;
|
||||
unit: string;
|
||||
active: boolean;
|
||||
sort_order: number;
|
||||
};
|
||||
|
||||
export async function getTimeTrackingTasksField(
|
||||
brandId: string,
|
||||
activeOnly = true
|
||||
): Promise<TimeTaskField[]> {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_tasks`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: supabaseKey,
|
||||
Authorization: `Bearer ${supabaseKey}`,
|
||||
},
|
||||
body: rpcBody({ p_brand_id: brandId, p_active_only: activeOnly }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data?.tasks ?? [];
|
||||
}
|
||||
|
||||
// ── Pay Period Hours ───────────────────────────────────────────────────────────
|
||||
|
||||
export type PayPeriodHours = {
|
||||
success: boolean;
|
||||
total_minutes: number;
|
||||
total_hours: number;
|
||||
daily_minutes: number;
|
||||
daily_hours: number;
|
||||
weekly_minutes: number;
|
||||
weekly_hours: number;
|
||||
daily_overtime: boolean;
|
||||
weekly_overtime: boolean;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
daily_threshold: number;
|
||||
weekly_threshold: number;
|
||||
};
|
||||
|
||||
export async function getWorkerPayPeriodHours(
|
||||
brandId: string
|
||||
): Promise<PayPeriodHours> {
|
||||
const session = await getTimeTrackingSession();
|
||||
if (!session) return { success: false, total_minutes: 0, total_hours: 0, daily_minutes: 0, daily_hours: 0, weekly_minutes: 0, weekly_hours: 0, daily_overtime: false, weekly_overtime: false, period_start: "", period_end: "", daily_threshold: 12, weekly_threshold: 56 };
|
||||
|
||||
const [hoursRes, settingsRes] = await Promise.all([
|
||||
fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_worker_pay_period_hours`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", apikey: supabaseKey, Authorization: `Bearer ${supabaseKey}` },
|
||||
body: rpcBody({ p_brand_id: brandId, p_worker_id: session.worker_id }),
|
||||
}
|
||||
),
|
||||
fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", apikey: supabaseKey, Authorization: `Bearer ${supabaseKey}` },
|
||||
body: rpcBody({ p_brand_id: brandId }),
|
||||
}
|
||||
),
|
||||
]);
|
||||
|
||||
const data = await hoursRes.json();
|
||||
const settings = settingsRes.ok ? (await settingsRes.json()) : null;
|
||||
|
||||
if (!hoursRes.ok) return { success: false, total_minutes: 0, total_hours: 0, daily_minutes: 0, daily_hours: 0, weekly_minutes: 0, weekly_hours: 0, daily_overtime: false, weekly_overtime: false, period_start: "", period_end: "", daily_threshold: 12, weekly_threshold: 56 };
|
||||
|
||||
return {
|
||||
...data,
|
||||
daily_threshold: settings ? Number(settings.daily_overtime_threshold) : 12,
|
||||
weekly_threshold: settings ? Number(settings.weekly_overtime_threshold) : 56,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { mockWorkers, mockTasks, mockTimeEntries } from "@/lib/mock-data";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Mock mode flag - only enabled when explicitly set
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
function rpcBody(body: Record<string, unknown>) {
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TimeWorker = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
lang: string;
|
||||
active: boolean;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type TimeTask = {
|
||||
id: string;
|
||||
name: string;
|
||||
name_es: string | null;
|
||||
unit: string;
|
||||
active: boolean;
|
||||
sort_order: number;
|
||||
};
|
||||
|
||||
export type TimeLog = {
|
||||
id: string;
|
||||
worker_id: string;
|
||||
worker_name: string;
|
||||
task_id: string | null;
|
||||
task_name: string;
|
||||
clock_in: string;
|
||||
clock_out: string | null;
|
||||
lunch_break_minutes: number;
|
||||
notes: string | null;
|
||||
submitted_via: string;
|
||||
total_minutes: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type TimeSummary = {
|
||||
by_worker: { id: string; name: string; entry_count: number; total_hours: number }[];
|
||||
by_task: { id: string; name: string; name_es: string | null; entry_count: number; total_hours: number }[];
|
||||
totals: { entry_count: number; total_hours: number; open_count: number };
|
||||
};
|
||||
|
||||
// ── Workers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTimeTrackingWorkers(brandId: string): Promise<TimeWorker[]> {
|
||||
if (useMockData) {
|
||||
return mockWorkers.map(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
role: w.role,
|
||||
lang: w.language,
|
||||
active: w.is_active,
|
||||
last_used_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_workers`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data?.workers ?? [];
|
||||
}
|
||||
|
||||
export async function createTimeWorker(
|
||||
brandId: string,
|
||||
name: string,
|
||||
role = "worker",
|
||||
lang = "en"
|
||||
): Promise<{ success: boolean; worker?: TimeWorker; pin?: string; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_time_worker`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_brand_id: brandId, p_name: name, p_role: role, p_lang: lang }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true, worker: data.worker, pin: data.pin };
|
||||
}
|
||||
|
||||
export async function resetTimeWorkerPin(workerId: string): Promise<{ success: boolean; pin?: string; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/reset_time_worker_pin`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_worker_id: workerId }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true, pin: data.pin };
|
||||
}
|
||||
|
||||
export async function updateTimeWorker(
|
||||
workerId: string,
|
||||
name: string,
|
||||
role: string,
|
||||
lang: string,
|
||||
active: boolean
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_time_worker`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_worker_id: workerId, p_name: name, p_role: role, p_lang: lang, p_active: active }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteTimeWorker(workerId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_time_worker`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_worker_id: workerId }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Tasks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTimeTrackingTasks(brandId: string, activeOnly = false): Promise<TimeTask[]> {
|
||||
if (useMockData) {
|
||||
return mockTasks.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name_en,
|
||||
name_es: t.name_es,
|
||||
unit: t.unit,
|
||||
active: true,
|
||||
sort_order: t.sort_order,
|
||||
}));
|
||||
}
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_tasks`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_brand_id: brandId, p_active_only: activeOnly }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data?.tasks ?? [];
|
||||
}
|
||||
|
||||
export async function createTimeTask(
|
||||
brandId: string,
|
||||
name: string,
|
||||
nameEs: string | null = null,
|
||||
unit = "hours",
|
||||
sortOrder = 0
|
||||
): Promise<{ success: boolean; id?: string; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_time_task`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_brand_id: brandId, p_name: name, p_name_es: nameEs, p_unit: unit, p_sort_order: sortOrder }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true, id: data.id };
|
||||
}
|
||||
|
||||
export async function updateTimeTask(
|
||||
taskId: string,
|
||||
name: string,
|
||||
nameEs: string,
|
||||
unit: string,
|
||||
active: boolean,
|
||||
sortOrder: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_time_task`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_task_id: taskId, p_name: name, p_name_es: nameEs, p_unit: unit, p_active: active, p_sort_order: sortOrder }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteTimeTask(taskId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_time_task`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_task_id: taskId }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Time Logs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getWorkerTimeLogs(
|
||||
brandId: string,
|
||||
options: {
|
||||
workerId?: string;
|
||||
taskId?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}
|
||||
): Promise<TimeLog[]> {
|
||||
if (useMockData) {
|
||||
// Filter by worker, task, date range
|
||||
let entries = mockTimeEntries.filter(e => e.brand_id === brandId);
|
||||
if (options.workerId) entries = entries.filter(e => e.worker_id === options.workerId);
|
||||
|
||||
return entries.map(e => {
|
||||
const worker = mockWorkers.find(w => w.id === e.worker_id);
|
||||
const task = mockTasks.find(t => t.id === e.task_id);
|
||||
return {
|
||||
id: e.id,
|
||||
worker_id: e.worker_id,
|
||||
worker_name: worker?.name ?? "Unknown",
|
||||
task_id: e.task_id,
|
||||
task_name: task?.name_en ?? "Unknown",
|
||||
clock_in: `${e.date}T09:00:00Z`,
|
||||
clock_out: `${e.date}T${9 + e.hours}:00:00Z`,
|
||||
lunch_break_minutes: 0,
|
||||
notes: null,
|
||||
submitted_via: "web",
|
||||
total_minutes: Math.round(e.hours * 60),
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_worker_time_logs`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({
|
||||
p_brand_id: brandId,
|
||||
p_worker_id: options.workerId ?? null,
|
||||
p_task_id: options.taskId ?? null,
|
||||
p_start: options.start ?? null,
|
||||
p_end: options.end ?? null,
|
||||
p_limit: options.limit ?? 200,
|
||||
p_offset: options.offset ?? 0,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data?.logs ?? [];
|
||||
}
|
||||
|
||||
export async function updateWorkerTimeLog(
|
||||
logId: string,
|
||||
taskName: string,
|
||||
clockIn: string,
|
||||
clockOut: string | null,
|
||||
lunchMinutes: number,
|
||||
notes: string | null
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_worker_time_log`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({
|
||||
p_log_id: logId,
|
||||
p_task_name: taskName,
|
||||
p_clock_in: clockIn,
|
||||
p_clock_out: clockOut,
|
||||
p_lunch_minutes: lunchMinutes,
|
||||
p_notes: notes,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteWorkerTimeLog(logId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_worker_time_log`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_log_id: logId }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getTimeTrackingSummary(
|
||||
brandId: string,
|
||||
start: string,
|
||||
end: string
|
||||
): Promise<TimeSummary> {
|
||||
if (useMockData) {
|
||||
const entries = mockTimeEntries.filter(e => e.brand_id === brandId);
|
||||
|
||||
// Calculate by worker
|
||||
const workerMap = new Map<string, { id: string; name: string; total_hours: number; entry_count: number }>();
|
||||
entries.forEach(e => {
|
||||
const worker = mockWorkers.find(w => w.id === e.worker_id);
|
||||
const existing = workerMap.get(e.worker_id) || { id: e.worker_id, name: worker?.name ?? "Unknown", total_hours: 0, entry_count: 0 };
|
||||
existing.total_hours += e.hours;
|
||||
existing.entry_count += 1;
|
||||
workerMap.set(e.worker_id, existing);
|
||||
});
|
||||
|
||||
// Calculate by task
|
||||
const taskMap = new Map<string, { id: string; name: string; name_es: string | null; total_hours: number; entry_count: number }>();
|
||||
entries.forEach(e => {
|
||||
const task = mockTasks.find(t => t.id === e.task_id);
|
||||
const existing = taskMap.get(e.task_id) || { id: e.task_id, name: task?.name_en ?? "Unknown", name_es: task?.name_es ?? null, total_hours: 0, entry_count: 0 };
|
||||
existing.total_hours += e.hours;
|
||||
existing.entry_count += 1;
|
||||
taskMap.set(e.task_id, existing);
|
||||
});
|
||||
|
||||
return {
|
||||
by_worker: Array.from(workerMap.values()),
|
||||
by_task: Array.from(taskMap.values()),
|
||||
totals: {
|
||||
entry_count: entries.length,
|
||||
total_hours: entries.reduce((sum, e) => sum + e.hours, 0),
|
||||
open_count: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_summary`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_brand_id: brandId, p_start: start, p_end: end }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return { by_worker: [], by_task: [], totals: { entry_count: 0, total_hours: 0, open_count: 0 } };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Time Tracking Settings ─────────────────────────────────────────────────────
|
||||
|
||||
export type TimeTrackingSettings = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
pay_period_start_day: number;
|
||||
pay_period_length_days: number;
|
||||
daily_overtime_threshold: number;
|
||||
weekly_overtime_threshold: number;
|
||||
overtime_multiplier: number;
|
||||
overtime_notifications: boolean;
|
||||
notification_emails: string[];
|
||||
notification_sms_numbers: string[];
|
||||
enable_daily_alerts: boolean;
|
||||
enable_weekly_alerts: boolean;
|
||||
daily_alert_threshold: number;
|
||||
weekly_alert_threshold: number;
|
||||
send_end_of_period_summary: boolean;
|
||||
brand_name: string;
|
||||
};
|
||||
|
||||
export async function getTimeTrackingSettings(brandId: string): Promise<TimeTrackingSettings | null> {
|
||||
if (useMockData) {
|
||||
return {
|
||||
id: "settings-mock",
|
||||
brand_id: brandId,
|
||||
pay_period_start_day: 0,
|
||||
pay_period_length_days: 7,
|
||||
daily_overtime_threshold: 8,
|
||||
weekly_overtime_threshold: 40,
|
||||
overtime_multiplier: 1.5,
|
||||
overtime_notifications: true,
|
||||
notification_emails: ["admin@tuxedocorn.com"],
|
||||
notification_sms_numbers: [],
|
||||
enable_daily_alerts: true,
|
||||
enable_weekly_alerts: true,
|
||||
daily_alert_threshold: 8,
|
||||
weekly_alert_threshold: 40,
|
||||
send_end_of_period_summary: true,
|
||||
brand_name: "Tuxedo Corn",
|
||||
};
|
||||
}
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateTimeTrackingSettings(
|
||||
brandId: string,
|
||||
settings: {
|
||||
pay_period_start_day: number;
|
||||
pay_period_length_days: number;
|
||||
daily_overtime_threshold: number;
|
||||
weekly_overtime_threshold: number;
|
||||
overtime_multiplier: number;
|
||||
overtime_notifications: boolean;
|
||||
notification_emails?: string[];
|
||||
notification_sms_numbers?: string[];
|
||||
enable_daily_alerts?: boolean;
|
||||
enable_weekly_alerts?: boolean;
|
||||
daily_alert_threshold?: number;
|
||||
weekly_alert_threshold?: number;
|
||||
send_end_of_period_summary?: boolean;
|
||||
brand_name?: string;
|
||||
}
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_time_tracking_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({
|
||||
p_brand_id: brandId,
|
||||
p_pay_period_start_day: settings.pay_period_start_day,
|
||||
p_pay_period_length_days: settings.pay_period_length_days,
|
||||
p_daily_threshold: settings.daily_overtime_threshold,
|
||||
p_weekly_threshold: settings.weekly_overtime_threshold,
|
||||
p_overtime_multiplier: settings.overtime_multiplier,
|
||||
p_overtime_notifications: settings.overtime_notifications,
|
||||
p_notification_emails: settings.notification_emails ?? null,
|
||||
p_notification_sms_numbers: settings.notification_sms_numbers ?? null,
|
||||
p_enable_daily_alerts: settings.enable_daily_alerts ?? null,
|
||||
p_enable_weekly_alerts: settings.enable_weekly_alerts ?? null,
|
||||
p_daily_alert_threshold: settings.daily_alert_threshold ?? null,
|
||||
p_weekly_alert_threshold: settings.weekly_alert_threshold ?? null,
|
||||
p_send_end_of_period_summary: settings.send_end_of_period_summary ?? null,
|
||||
p_brand_name: settings.brand_name ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Notification Log ─────────────────────────────────────────────────────────
|
||||
|
||||
export type NotificationLogEntry = {
|
||||
id: string;
|
||||
worker_id: string | null;
|
||||
worker_name: string | null;
|
||||
trigger_type: string;
|
||||
threshold_hours: number | null;
|
||||
actual_hours: number | null;
|
||||
emails_sent: string[];
|
||||
sms_numbers_sent: string[];
|
||||
email_sent: boolean;
|
||||
sms_sent: boolean;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export async function getTimeTrackingNotificationLog(
|
||||
brandId: string,
|
||||
limit = 100
|
||||
): Promise<NotificationLogEntry[]> {
|
||||
if (useMockData) {
|
||||
// Return empty log in mock mode
|
||||
return [];
|
||||
}
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_notification_log`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: rpcBody({ p_brand_id: brandId, p_limit: limit }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data ?? [];
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
export type OvertimeCheckResult = {
|
||||
sent: boolean;
|
||||
trigger_type?: string;
|
||||
message?: string;
|
||||
notification_log_id?: string;
|
||||
};
|
||||
|
||||
export async function checkAndNotifyOvertime(
|
||||
brandId: string,
|
||||
workerId: string,
|
||||
workerName: string,
|
||||
dailyHours: number,
|
||||
weeklyHours: number
|
||||
): Promise<OvertimeCheckResult> {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/check_and_notify_overtime`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_worker_id: workerId,
|
||||
p_worker_name: workerName,
|
||||
p_daily_hours: dailyHours,
|
||||
p_weekly_hours: weeklyHours,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return { sent: false, message: err };
|
||||
}
|
||||
const data = await res.json();
|
||||
return {
|
||||
sent: Boolean(data?.sent),
|
||||
trigger_type: data?.trigger_type,
|
||||
message: data?.message,
|
||||
notification_log_id: data?.notification_log_id,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user