1052 lines
26 KiB
Vue
1052 lines
26 KiB
Vue
<template>
|
|
<div class="timesheets-page">
|
|
<div class="timesheets-header">
|
|
<h2>Employee Timesheets</h2>
|
|
<p class="timesheets-subtitle">
|
|
Track time, manage approvals, and monitor labor costs
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Controls Section -->
|
|
<div class="controls-section">
|
|
<div class="filter-controls">
|
|
<div class="filter-group">
|
|
<label>Week Filter:</label>
|
|
<v-select
|
|
v-model="selectedWeek"
|
|
:items="weekOptions"
|
|
item-title="label"
|
|
item-value="value"
|
|
density="compact"
|
|
variant="outlined"
|
|
@update:modelValue="filterByWeek"
|
|
></v-select>
|
|
</div>
|
|
|
|
<div class="filter-group">
|
|
<label>Employee:</label>
|
|
<v-select
|
|
v-model="selectedEmployee"
|
|
:items="employeeOptions"
|
|
item-title="label"
|
|
item-value="value"
|
|
density="compact"
|
|
variant="outlined"
|
|
clearable
|
|
@update:modelValue="filterByEmployee"
|
|
></v-select>
|
|
</div>
|
|
|
|
<div class="filter-group">
|
|
<label>Status:</label>
|
|
<v-select
|
|
v-model="selectedStatus"
|
|
:items="statusOptions"
|
|
item-title="label"
|
|
item-value="value"
|
|
density="compact"
|
|
variant="outlined"
|
|
clearable
|
|
@update:modelValue="filterByStatus"
|
|
></v-select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="action-buttons">
|
|
<v-btn color="primary" @click="addNewTimesheet" prepend-icon="mdi-plus">
|
|
Add Timesheet
|
|
</v-btn>
|
|
<v-btn
|
|
color="success"
|
|
@click="bulkApprove"
|
|
prepend-icon="mdi-check-all"
|
|
:disabled="!hasSelectedForApproval"
|
|
>
|
|
Bulk Approve
|
|
</v-btn>
|
|
<v-btn color="info" @click="exportTimesheets" prepend-icon="mdi-download">
|
|
Export
|
|
</v-btn>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Summary Cards -->
|
|
<div class="summary-cards">
|
|
<v-card class="summary-card">
|
|
<v-card-text>
|
|
<div class="summary-content">
|
|
<v-icon color="primary" size="large">mdi-clock</v-icon>
|
|
<div class="summary-text">
|
|
<div class="summary-number">{{ totalHoursThisWeek }}</div>
|
|
<div class="summary-label">Hours This Week</div>
|
|
</div>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<v-card class="summary-card">
|
|
<v-card-text>
|
|
<div class="summary-content">
|
|
<v-icon color="warning" size="large">mdi-clock-alert</v-icon>
|
|
<div class="summary-text">
|
|
<div class="summary-number">{{ pendingApprovals }}</div>
|
|
<div class="summary-label">Pending Approval</div>
|
|
</div>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<v-card class="summary-card">
|
|
<v-card-text>
|
|
<div class="summary-content">
|
|
<v-icon color="success" size="large">mdi-currency-usd</v-icon>
|
|
<div class="summary-text">
|
|
<div class="summary-number">${{ totalLaborCost }}</div>
|
|
<div class="summary-label">Labor Cost</div>
|
|
</div>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<v-card class="summary-card">
|
|
<v-card-text>
|
|
<div class="summary-content">
|
|
<v-icon color="info" size="large">mdi-account-group</v-icon>
|
|
<div class="summary-text">
|
|
<div class="summary-number">{{ activeEmployees }}</div>
|
|
<div class="summary-label">Active Employees</div>
|
|
</div>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</div>
|
|
|
|
<!-- Main Timesheet Table -->
|
|
<div class="timesheets-table-container">
|
|
<DataTable
|
|
:data="tableData"
|
|
:columns="columns"
|
|
tableName="timesheets"
|
|
:lazy="true"
|
|
:totalRecords="totalRecords"
|
|
:loading="isLoading"
|
|
:onLazyLoad="handleLazyLoad"
|
|
@lazy-load="handleLazyLoad"
|
|
@row-click="viewTimesheetDetails"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Timesheet Details Modal -->
|
|
<v-dialog v-model="timesheetDialog" max-width="1000px" persistent>
|
|
<v-card v-if="selectedTimesheet">
|
|
<v-card-title class="d-flex justify-space-between align-center pa-4">
|
|
<div>
|
|
<h3>Timesheet Details - {{ selectedTimesheet.timesheetId }}</h3>
|
|
<span class="text-subtitle-1 text-medium-emphasis">
|
|
{{ selectedTimesheet.employee }} -
|
|
{{ formatDate(selectedTimesheet.date) }}
|
|
</span>
|
|
</div>
|
|
<div class="d-flex align-center gap-2">
|
|
<v-chip :color="getStatusColor(selectedTimesheet.status)" size="small">
|
|
{{ selectedTimesheet.status.toUpperCase() }}
|
|
</v-chip>
|
|
<v-btn
|
|
icon="mdi-close"
|
|
variant="text"
|
|
@click="timesheetDialog = false"
|
|
></v-btn>
|
|
</div>
|
|
</v-card-title>
|
|
|
|
<v-divider></v-divider>
|
|
|
|
<v-card-text class="pa-0">
|
|
<div class="timesheet-details-container">
|
|
<!-- Left Panel - Basic Info -->
|
|
<div class="details-left-panel pa-4">
|
|
<h4 class="mb-3">Time & Job Information</h4>
|
|
|
|
<div class="detail-grid">
|
|
<div class="detail-item">
|
|
<v-icon class="mr-2" size="small">mdi-account</v-icon>
|
|
<span class="label">Employee:</span>
|
|
<span class="value">{{ selectedTimesheet.employee }}</span>
|
|
</div>
|
|
|
|
<div class="detail-item">
|
|
<v-icon class="mr-2" size="small">mdi-calendar</v-icon>
|
|
<span class="label">Date:</span>
|
|
<span class="value">{{
|
|
formatDate(selectedTimesheet.date)
|
|
}}</span>
|
|
</div>
|
|
|
|
<div class="detail-item">
|
|
<v-icon class="mr-2" size="small">mdi-briefcase</v-icon>
|
|
<span class="label">Job ID:</span>
|
|
<span class="value">{{ selectedTimesheet.jobId }}</span>
|
|
</div>
|
|
|
|
<div class="detail-item">
|
|
<v-icon class="mr-2" size="small">mdi-account-hard-hat</v-icon>
|
|
<span class="label">Customer:</span>
|
|
<span class="value">{{ selectedTimesheet.customer }}</span>
|
|
</div>
|
|
|
|
<div class="detail-item">
|
|
<v-icon class="mr-2" size="small">mdi-map-marker</v-icon>
|
|
<span class="label">Address:</span>
|
|
<span class="value">{{ selectedTimesheet.address }}</span>
|
|
</div>
|
|
|
|
<div class="detail-item">
|
|
<v-icon class="mr-2" size="small">mdi-wrench</v-icon>
|
|
<span class="label">Task:</span>
|
|
<span class="value">{{
|
|
selectedTimesheet.taskDescription
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Time Details -->
|
|
<h4 class="mt-4 mb-3">Time Details</h4>
|
|
<div class="time-details">
|
|
<div class="time-row">
|
|
<span class="time-label">Clock In:</span>
|
|
<span class="time-value">{{ selectedTimesheet.clockIn }}</span>
|
|
</div>
|
|
<div class="time-row">
|
|
<span class="time-label">Clock Out:</span>
|
|
<span class="time-value">{{
|
|
selectedTimesheet.clockOut
|
|
}}</span>
|
|
</div>
|
|
<div class="time-row">
|
|
<span class="time-label">Break Time:</span>
|
|
<span class="time-value"
|
|
>{{ selectedTimesheet.breakTime }} min</span
|
|
>
|
|
</div>
|
|
<div class="time-row">
|
|
<span class="time-label">Regular Hours:</span>
|
|
<span class="time-value">{{
|
|
selectedTimesheet.regularHours
|
|
}}</span>
|
|
</div>
|
|
<div class="time-row">
|
|
<span class="time-label">Overtime Hours:</span>
|
|
<span class="time-value">{{
|
|
selectedTimesheet.overtimeHours
|
|
}}</span>
|
|
</div>
|
|
<div class="time-row total-row">
|
|
<span class="time-label">Total Hours:</span>
|
|
<span class="time-value">{{
|
|
selectedTimesheet.totalHours
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pay Details -->
|
|
<h4 class="mt-4 mb-3">Pay Calculation</h4>
|
|
<div class="pay-details">
|
|
<div class="pay-row">
|
|
<span class="pay-label">Regular Rate:</span>
|
|
<span class="pay-value"
|
|
>${{ selectedTimesheet.hourlyRate }}/hr</span
|
|
>
|
|
</div>
|
|
<div class="pay-row">
|
|
<span class="pay-label">Overtime Rate:</span>
|
|
<span class="pay-value"
|
|
>${{ selectedTimesheet.overtimeRate }}/hr</span
|
|
>
|
|
</div>
|
|
<div class="pay-row">
|
|
<span class="pay-label">Mileage:</span>
|
|
<span class="pay-value"
|
|
>{{ selectedTimesheet.mileage }} miles</span
|
|
>
|
|
</div>
|
|
<div class="pay-row total-row">
|
|
<span class="pay-label">Total Pay:</span>
|
|
<span class="pay-value"
|
|
>${{ selectedTimesheet.totalPay }}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Panel - Equipment, Materials, Notes -->
|
|
<div class="details-right-panel pa-4">
|
|
<h4 class="mb-3">Work Details</h4>
|
|
|
|
<!-- Equipment Used -->
|
|
<div class="work-section mb-4">
|
|
<h5 class="mb-2">Equipment Used:</h5>
|
|
<v-chip-group>
|
|
<v-chip
|
|
v-for="equipment in selectedTimesheet.equipment"
|
|
:key="equipment"
|
|
size="small"
|
|
color="primary"
|
|
variant="outlined"
|
|
>
|
|
{{ equipment }}
|
|
</v-chip>
|
|
</v-chip-group>
|
|
</div>
|
|
|
|
<!-- Materials Used -->
|
|
<div class="work-section mb-4">
|
|
<h5 class="mb-2">Materials Used:</h5>
|
|
<div class="materials-list">
|
|
<div
|
|
v-for="material in selectedTimesheet.materials"
|
|
:key="material"
|
|
class="material-item"
|
|
>
|
|
<v-icon size="small" class="mr-2"
|
|
>mdi-package-variant</v-icon
|
|
>
|
|
{{ material }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Work Notes -->
|
|
<div class="work-section mb-4">
|
|
<h5 class="mb-2">Work Notes:</h5>
|
|
<div class="notes-content">
|
|
{{ selectedTimesheet.notes }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Approval Status -->
|
|
<div class="approval-section">
|
|
<h5 class="mb-2">Approval Status:</h5>
|
|
<div class="approval-info">
|
|
<div v-if="selectedTimesheet.approved" class="approved-info">
|
|
<v-icon color="success" class="mr-2"
|
|
>mdi-check-circle</v-icon
|
|
>
|
|
<div>
|
|
<div class="approval-text">
|
|
Approved by {{ selectedTimesheet.approvedBy }}
|
|
</div>
|
|
<div class="approval-date">
|
|
{{ formatDate(selectedTimesheet.approvedDate) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="pending-info">
|
|
<v-icon color="warning" class="mr-2">mdi-clock</v-icon>
|
|
<span>Pending approval</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</v-card-text>
|
|
|
|
<v-divider></v-divider>
|
|
|
|
<v-card-actions class="pa-4">
|
|
<v-spacer></v-spacer>
|
|
<v-btn color="grey" variant="outlined" @click="timesheetDialog = false">
|
|
Close
|
|
</v-btn>
|
|
<v-btn
|
|
v-if="!selectedTimesheet.approved"
|
|
color="success"
|
|
@click="approveTimesheet(selectedTimesheet)"
|
|
>
|
|
Approve
|
|
</v-btn>
|
|
<v-btn color="primary" @click="editTimesheet(selectedTimesheet)"> Edit </v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, computed } from "vue";
|
|
import DataTable from "../common/DataTable.vue";
|
|
import Api from "../../api";
|
|
import { FilterMatchMode } from "@primevue/core";
|
|
import { useLoadingStore } from "../../stores/loading";
|
|
import { usePaginationStore } from "../../stores/pagination";
|
|
import { useFiltersStore } from "../../stores/filters";
|
|
import { useNotificationStore } from "../../stores/notifications-primevue";
|
|
|
|
const notifications = useNotificationStore();
|
|
const loadingStore = useLoadingStore();
|
|
const paginationStore = usePaginationStore();
|
|
const filtersStore = useFiltersStore();
|
|
|
|
// Reactive data
|
|
const tableData = ref([]);
|
|
const totalRecords = ref(0);
|
|
const isLoading = ref(false);
|
|
const timesheetDialog = ref(false);
|
|
const selectedTimesheet = ref(null);
|
|
const allTimesheetData = ref([]); // Store all data for filtering and calculations
|
|
|
|
// Filter controls
|
|
const selectedWeek = ref("current");
|
|
const selectedEmployee = ref(null);
|
|
const selectedStatus = ref(null);
|
|
|
|
// Filter options
|
|
const weekOptions = [
|
|
{ label: "Current Week", value: "current" },
|
|
{ label: "Last Week", value: "last" },
|
|
{ label: "Last 2 Weeks", value: "last2" },
|
|
{ label: "All Weeks", value: "all" },
|
|
];
|
|
|
|
const employeeOptions = ref([]);
|
|
const statusOptions = [
|
|
{ label: "Draft", value: "draft" },
|
|
{ label: "Submitted", value: "submitted" },
|
|
{ label: "Approved", value: "approved" },
|
|
];
|
|
|
|
// Table configuration
|
|
const columns = [
|
|
{ label: "Timesheet ID", fieldName: "timesheetId", type: "text", sortable: true },
|
|
{ label: "Employee", fieldName: "employee", type: "text", sortable: true, filterable: true },
|
|
{ label: "Date", fieldName: "date", type: "text", sortable: true },
|
|
{ label: "Job ID", fieldName: "jobId", type: "text", sortable: true },
|
|
{ label: "Customer", fieldName: "customer", type: "text", sortable: true },
|
|
{ label: "Total Hours", fieldName: "totalHours", type: "text", sortable: true },
|
|
{ label: "Status", fieldName: "status", type: "status", sortable: true },
|
|
{ label: "Total Pay", fieldName: "totalPayFormatted", type: "text", sortable: true },
|
|
{ label: "Actions", fieldName: "actions", type: "button", sortable: false },
|
|
];
|
|
|
|
// Handle lazy loading events from DataTable
|
|
const handleLazyLoad = async (event) => {
|
|
console.log("TimeSheets page - handling lazy load:", event);
|
|
|
|
try {
|
|
isLoading.value = true;
|
|
|
|
// Get pagination parameters
|
|
const paginationParams = {
|
|
page: event.page || 0,
|
|
pageSize: event.rows || 10,
|
|
sortField: event.sortField,
|
|
sortOrder: event.sortOrder,
|
|
};
|
|
|
|
// Get filters (convert PrimeVue format to API format)
|
|
const filters = {};
|
|
if (event.filters) {
|
|
Object.keys(event.filters).forEach((key) => {
|
|
if (key !== "global" && event.filters[key] && event.filters[key].value) {
|
|
filters[key] = event.filters[key];
|
|
}
|
|
});
|
|
}
|
|
|
|
// Apply additional filters from the controls
|
|
const additionalFilters = {
|
|
week: selectedWeek.value,
|
|
employee: selectedEmployee.value,
|
|
status: selectedStatus.value,
|
|
};
|
|
|
|
// Combine filters
|
|
const combinedFilters = { ...filters, ...additionalFilters };
|
|
|
|
// Check cache first
|
|
const cacheKey = `${JSON.stringify(combinedFilters)}`;
|
|
const cachedData = paginationStore.getCachedPage(
|
|
"timesheets",
|
|
paginationParams.page,
|
|
paginationParams.pageSize,
|
|
paginationParams.sortField,
|
|
paginationParams.sortOrder,
|
|
combinedFilters,
|
|
);
|
|
|
|
if (cachedData) {
|
|
// Use cached data
|
|
tableData.value = cachedData.records;
|
|
totalRecords.value = cachedData.totalRecords;
|
|
allTimesheetData.value = cachedData.allData || [];
|
|
paginationStore.setTotalRecords("timesheets", cachedData.totalRecords);
|
|
|
|
console.log("Loaded from cache:", {
|
|
records: cachedData.records.length,
|
|
total: cachedData.totalRecords,
|
|
page: paginationParams.page + 1,
|
|
});
|
|
return;
|
|
}
|
|
|
|
console.log("Making API call with:", { paginationParams, combinedFilters });
|
|
|
|
// For now, use existing API but we should create a paginated version
|
|
// TODO: Create Api.getPaginatedTimesheetData() method
|
|
const data = await Api.getTimesheetData();
|
|
|
|
// Store all data for calculations
|
|
allTimesheetData.value = data;
|
|
|
|
// Apply local filtering based on controls
|
|
let filteredData = data;
|
|
|
|
// Week filter
|
|
if (selectedWeek.value !== "all") {
|
|
const currentWeekStart = getCurrentWeekStart();
|
|
let weekStart;
|
|
|
|
switch (selectedWeek.value) {
|
|
case "current":
|
|
weekStart = currentWeekStart;
|
|
break;
|
|
case "last":
|
|
weekStart = getWeekStart(1);
|
|
break;
|
|
case "last2":
|
|
weekStart = getWeekStart(2);
|
|
break;
|
|
default:
|
|
weekStart = null;
|
|
}
|
|
|
|
if (weekStart) {
|
|
filteredData = filteredData.filter((timesheet) => {
|
|
const timesheetDate = new Date(timesheet.date);
|
|
return timesheetDate >= weekStart;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Employee filter
|
|
if (selectedEmployee.value) {
|
|
filteredData = filteredData.filter((ts) => ts.employee === selectedEmployee.value);
|
|
}
|
|
|
|
// Status filter
|
|
if (selectedStatus.value) {
|
|
filteredData = filteredData.filter((ts) => ts.status === selectedStatus.value);
|
|
}
|
|
|
|
// Simulate pagination on filtered data
|
|
const startIndex = paginationParams.page * paginationParams.pageSize;
|
|
const endIndex = startIndex + paginationParams.pageSize;
|
|
const paginatedData = filteredData.slice(startIndex, endIndex);
|
|
|
|
// Update local state
|
|
tableData.value = paginatedData;
|
|
totalRecords.value = filteredData.length;
|
|
|
|
// Update pagination store with new total
|
|
paginationStore.setTotalRecords("timesheets", filteredData.length);
|
|
|
|
// Cache the result
|
|
paginationStore.setCachedPage(
|
|
"timesheets",
|
|
paginationParams.page,
|
|
paginationParams.pageSize,
|
|
paginationParams.sortField,
|
|
paginationParams.sortOrder,
|
|
combinedFilters,
|
|
{
|
|
records: paginatedData,
|
|
totalRecords: filteredData.length,
|
|
allData: data,
|
|
},
|
|
);
|
|
|
|
console.log("Loaded from API:", {
|
|
records: paginatedData.length,
|
|
total: filteredData.length,
|
|
page: paginationParams.page + 1,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error loading timesheet data:", error);
|
|
tableData.value = [];
|
|
totalRecords.value = 0;
|
|
allTimesheetData.value = [];
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
};
|
|
|
|
// Computed properties
|
|
const totalHoursThisWeek = computed(() => {
|
|
const currentWeekData = getCurrentWeekTimesheets();
|
|
return currentWeekData
|
|
.reduce((total, timesheet) => total + timesheet.totalHours, 0)
|
|
.toFixed(1);
|
|
});
|
|
|
|
const pendingApprovals = computed(() => {
|
|
return allTimesheetData.value.filter((ts) => !ts.approved).length;
|
|
});
|
|
|
|
const totalLaborCost = computed(() => {
|
|
const currentWeekData = getCurrentWeekTimesheets();
|
|
const total = currentWeekData.reduce((sum, timesheet) => sum + timesheet.totalPay, 0);
|
|
return total.toLocaleString();
|
|
});
|
|
|
|
const activeEmployees = computed(() => {
|
|
const uniqueEmployees = new Set(allTimesheetData.value.map((ts) => ts.employee));
|
|
return uniqueEmployees.size;
|
|
});
|
|
|
|
const hasSelectedForApproval = computed(() => {
|
|
// In a real implementation, this would check selected rows
|
|
return pendingApprovals.value > 0;
|
|
});
|
|
|
|
// Methods
|
|
// Methods
|
|
const getCurrentWeekTimesheets = () => {
|
|
const currentWeekStart = getCurrentWeekStart();
|
|
return allTimesheetData.value.filter((timesheet) => {
|
|
const timesheetDate = new Date(timesheet.date);
|
|
return timesheetDate >= currentWeekStart;
|
|
});
|
|
};
|
|
|
|
const getCurrentWeekStart = () => {
|
|
const now = new Date();
|
|
const dayOfWeek = now.getDay();
|
|
const diff = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust for Sunday
|
|
return new Date(now.setDate(diff));
|
|
};
|
|
|
|
const getWeekStart = (weeksAgo) => {
|
|
const now = new Date();
|
|
const dayOfWeek = now.getDay();
|
|
const diff = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1) - weeksAgo * 7;
|
|
return new Date(now.setDate(diff));
|
|
};
|
|
|
|
const filterByWeek = () => {
|
|
// Reset to first page when filters change
|
|
paginationStore.resetToFirstPage("timesheets");
|
|
triggerLazyLoad();
|
|
};
|
|
|
|
const filterByEmployee = () => {
|
|
// Reset to first page when filters change
|
|
paginationStore.resetToFirstPage("timesheets");
|
|
triggerLazyLoad();
|
|
};
|
|
|
|
const filterByStatus = () => {
|
|
// Reset to first page when filters change
|
|
paginationStore.resetToFirstPage("timesheets");
|
|
triggerLazyLoad();
|
|
};
|
|
|
|
const triggerLazyLoad = () => {
|
|
const paginationParams = paginationStore.getPaginationParams("timesheets");
|
|
const filters = filtersStore.getTableFilters("timesheets");
|
|
|
|
const lazyEvent = {
|
|
page: paginationParams.page,
|
|
rows: paginationParams.pageSize,
|
|
first: paginationParams.offset,
|
|
sortField: paginationParams.sortField,
|
|
sortOrder: paginationParams.sortOrder,
|
|
filters: filters,
|
|
};
|
|
|
|
console.log("Triggering lazy load with:", lazyEvent);
|
|
handleLazyLoad(lazyEvent);
|
|
};
|
|
|
|
const applyFilters = () => {
|
|
let filtered = [...tableData.value];
|
|
|
|
// Week filter
|
|
if (selectedWeek.value !== "all") {
|
|
const weeksAgo =
|
|
selectedWeek.value === "current" ? 0 : selectedWeek.value === "last" ? 1 : 2;
|
|
const weekStart = getWeekStart(weeksAgo);
|
|
const weekEnd =
|
|
selectedWeek.value === "last2"
|
|
? new Date()
|
|
: new Date(weekStart.getTime() + 7 * 24 * 60 * 60 * 1000);
|
|
|
|
filtered = filtered.filter((timesheet) => {
|
|
const timesheetDate = new Date(timesheet.date);
|
|
return timesheetDate >= weekStart && timesheetDate < weekEnd;
|
|
});
|
|
}
|
|
|
|
// Employee filter
|
|
if (selectedEmployee.value) {
|
|
filtered = filtered.filter((ts) => ts.employee === selectedEmployee.value);
|
|
}
|
|
|
|
// Status filter
|
|
if (selectedStatus.value) {
|
|
filtered = filtered.filter((ts) => ts.status === selectedStatus.value);
|
|
}
|
|
|
|
filteredTableData.value = filtered;
|
|
};
|
|
|
|
const viewTimesheetDetails = (event) => {
|
|
const timesheetId = event.data.timesheetId;
|
|
const timesheet = allTimesheetData.value.find((ts) => ts.timesheetId === timesheetId);
|
|
if (timesheet) {
|
|
selectedTimesheet.value = timesheet;
|
|
timesheetDialog.value = true;
|
|
}
|
|
};
|
|
|
|
const addNewTimesheet = () => {
|
|
console.log("Add new timesheet clicked");
|
|
// TODO: Implement add timesheet functionality
|
|
};
|
|
|
|
const bulkApprove = () => {
|
|
console.log("Bulk approve clicked");
|
|
// TODO: Implement bulk approval functionality
|
|
};
|
|
|
|
const exportTimesheets = () => {
|
|
console.log("Export timesheets clicked");
|
|
// TODO: Implement export functionality
|
|
};
|
|
|
|
const approveTimesheet = (timesheet) => {
|
|
timesheet.approved = true;
|
|
timesheet.approvedBy = "Current User"; // In real app, get from user context
|
|
timesheet.approvedDate = new Date().toISOString().split("T")[0];
|
|
timesheet.status = "approved";
|
|
console.log("Approved timesheet:", timesheet.timesheetId);
|
|
};
|
|
|
|
const editTimesheet = (timesheet) => {
|
|
console.log("Edit timesheet:", timesheet.timesheetId);
|
|
// TODO: Implement edit functionality
|
|
};
|
|
|
|
const getStatusColor = (status) => {
|
|
switch (status?.toLowerCase()) {
|
|
case "approved":
|
|
return "success";
|
|
case "submitted":
|
|
return "warning";
|
|
case "draft":
|
|
return "grey";
|
|
default:
|
|
return "info";
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateString) => {
|
|
if (!dateString) return "N/A";
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString();
|
|
};
|
|
|
|
// Load data on component mount
|
|
onMounted(async () => {
|
|
notifications.addWarning("Timesheets page coming soon!");
|
|
try {
|
|
// Initialize pagination and filters
|
|
paginationStore.initializeTablePagination("timesheets", { rows: 10 });
|
|
filtersStore.initializeTableFilters("timesheets", columns);
|
|
|
|
// Load data to set up employee options
|
|
const data = await Api.getTimesheetData();
|
|
allTimesheetData.value = data.map((timesheet) => ({
|
|
...timesheet,
|
|
totalPayFormatted: `$${timesheet.totalPay.toLocaleString()}`,
|
|
actions: "View Details",
|
|
}));
|
|
|
|
// Set up employee options
|
|
const uniqueEmployees = [...new Set(data.map((ts) => ts.employee))];
|
|
employeeOptions.value = uniqueEmployees.map((emp) => ({ label: emp, value: emp }));
|
|
|
|
// Load first page
|
|
const initialPagination = paginationStore.getTablePagination("timesheets");
|
|
const initialFilters = filtersStore.getTableFilters("timesheets");
|
|
|
|
await handleLazyLoad({
|
|
page: initialPagination.page,
|
|
rows: initialPagination.rows,
|
|
first: initialPagination.first,
|
|
sortField: initialPagination.sortField,
|
|
sortOrder: initialPagination.sortOrder,
|
|
filters: initialFilters,
|
|
});
|
|
|
|
console.log("Loaded timesheets:", allTimesheetData.value.length);
|
|
} catch (error) {
|
|
console.error("Error loading timesheets:", error);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.timesheets-page {
|
|
padding: 20px;
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.timesheets-header {
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.timesheets-header h2 {
|
|
margin-bottom: 8px;
|
|
color: #1976d2;
|
|
}
|
|
|
|
.timesheets-subtitle {
|
|
color: #666;
|
|
margin: 0;
|
|
}
|
|
|
|
.controls-section {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-end;
|
|
margin-bottom: 24px;
|
|
padding: 16px;
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.filter-controls {
|
|
display: flex;
|
|
gap: 16px;
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.filter-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.filter-group label {
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.action-buttons {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.summary-cards {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.summary-card {
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.summary-content {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.summary-text {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.summary-number {
|
|
font-size: 1.8em;
|
|
font-weight: 600;
|
|
color: #1976d2;
|
|
}
|
|
|
|
.summary-label {
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
}
|
|
|
|
.timesheets-table-container {
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.timesheet-details-container {
|
|
display: flex;
|
|
min-height: 600px;
|
|
}
|
|
|
|
.details-left-panel {
|
|
flex: 1;
|
|
border-right: 1px solid #e0e0e0;
|
|
background-color: #fafafa;
|
|
}
|
|
|
|
.details-right-panel {
|
|
flex: 1;
|
|
}
|
|
|
|
.detail-grid {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.detail-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.detail-item .label {
|
|
font-weight: 500;
|
|
min-width: 80px;
|
|
color: #666;
|
|
}
|
|
|
|
.detail-item .value {
|
|
color: #333;
|
|
}
|
|
|
|
.time-details,
|
|
.pay-details {
|
|
background: white;
|
|
padding: 16px;
|
|
border-radius: 8px;
|
|
border: 1px solid #e0e0e0;
|
|
}
|
|
|
|
.time-row,
|
|
.pay-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
|
|
.time-row:last-child,
|
|
.pay-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.total-row {
|
|
font-weight: 600;
|
|
background-color: #f8f9fa;
|
|
margin-top: 8px;
|
|
padding: 12px 8px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.time-label,
|
|
.pay-label {
|
|
font-weight: 500;
|
|
color: #666;
|
|
}
|
|
|
|
.time-value,
|
|
.pay-value {
|
|
color: #333;
|
|
}
|
|
|
|
.work-section h5 {
|
|
color: #1976d2;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.materials-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.material-item {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 8px 12px;
|
|
background-color: #f8f9fa;
|
|
border-radius: 4px;
|
|
border-left: 3px solid #1976d2;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.notes-content {
|
|
background-color: #f8f9fa;
|
|
padding: 16px;
|
|
border-radius: 8px;
|
|
border-left: 4px solid #1976d2;
|
|
font-style: italic;
|
|
color: #555;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.approval-section {
|
|
background-color: #f8f9fa;
|
|
padding: 16px;
|
|
border-radius: 8px;
|
|
border: 1px solid #e0e0e0;
|
|
}
|
|
|
|
.approved-info,
|
|
.pending-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.approval-text {
|
|
font-weight: 500;
|
|
color: #333;
|
|
}
|
|
|
|
.approval-date {
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
}
|
|
|
|
/* Responsive design */
|
|
@media (max-width: 768px) {
|
|
.controls-section {
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.filter-controls {
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.summary-cards {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
|
|
.timesheet-details-container {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.details-left-panel {
|
|
border-right: none;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
}
|
|
}
|
|
</style>
|