fix: crash bugs in TeamInvoices + faster load
Bug fixes: - TeamInvoices: add useAuth import/currentUser (invoice-create crash) - TeamInvoices: setChartYear -> setExportYear (year-dropdown crash) - TeamDashboard: drop redundant setState-in-effect (cascading renders) - Companies: delete dead _UnusedClientCompanies (illegal hook calls) - annotate intentional empty PDF-fallback catches Load speed: - preconnect/dns-prefetch to Supabase origin - lazy-load heic-to in Converters: page chunk 2737KB -> 9KB - split recharts into its own 'charts' vendor chunk Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import { formatLongDate } from './dates';
|
||||
|
||||
function formatDate(dateStr) {
|
||||
return formatLongDate(dateStr ? `${dateStr}T12:00:00` : '', '-');
|
||||
}
|
||||
|
||||
// Letter landscape: 792 x 612 pt
|
||||
const W = 792;
|
||||
@@ -8,12 +13,6 @@ const ACCENT = [245, 165, 35];
|
||||
const DARK = [18, 18, 18];
|
||||
const HEADER_H = 64;
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function formatCoverAddress(address) {
|
||||
const parts = String(address || '')
|
||||
.split(',')
|
||||
@@ -384,7 +383,7 @@ export async function generateBrandBookEditorPDF(data) {
|
||||
let pageNum = 0;
|
||||
const rev = String(revision || '01').padStart(2, '0');
|
||||
const displayDate = bookDate
|
||||
? new Date(bookDate + 'T12:00:00').toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
? formatLongDate(`${bookDate}T12:00:00`, '-')
|
||||
: '';
|
||||
|
||||
// ─── COVER PAGE ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -8,6 +8,46 @@ export function formatDateEST(value) {
|
||||
});
|
||||
}
|
||||
|
||||
export function formatShortDate(value, fallback = '—') {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return fallback;
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatLongDate(value, fallback = '—') {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return fallback;
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatShortDateTime(value, fallback = '—') {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return fallback;
|
||||
return `${formatShortDate(date, fallback)} ${date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function formatDateAtNoon(value, fallback = '—') {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(`${value}T12:00:00`);
|
||||
if (Number.isNaN(date.getTime())) return fallback;
|
||||
return formatLongDate(date, fallback);
|
||||
}
|
||||
|
||||
export function parseDateOnly(value) {
|
||||
if (!value) return null;
|
||||
const [year, month, day] = String(value).split('-').map(Number);
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
const FBQ_FN = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/fbq-proxy`;
|
||||
|
||||
async function fbq(op, path, extra = {}) {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) return;
|
||||
const headers = {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'X-Operation': op,
|
||||
'X-Path': path,
|
||||
...extra.headers,
|
||||
};
|
||||
await fetch(FBQ_FN, { method: 'POST', headers, body: extra.body ?? undefined }).catch(() => {});
|
||||
}
|
||||
|
||||
export function safeName(v) {
|
||||
return String(v || '').trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export async function createProjectFolder(companyName, projectName) {
|
||||
if (!companyName || !projectName) return;
|
||||
const co = safeName(companyName);
|
||||
const proj = safeName(projectName);
|
||||
await fbq('mkdir', `/Clients/${co}/`);
|
||||
await fbq('mkdir', `/Clients/${co}/Projects/`);
|
||||
await fbq('mkdir', `/Clients/${co}/Projects/${proj}/`);
|
||||
await fbq('mkdir', `/Clients/${co}/Projects/${proj}/00 Project Files/`);
|
||||
}
|
||||
|
||||
export async function createTeamMemberFolder(name) {
|
||||
if (!name) return;
|
||||
await fbq('mkdir', '/Team/');
|
||||
await fbq('mkdir', `/Team/${safeName(name)}/`);
|
||||
}
|
||||
|
||||
export async function createTaskFolder(companyName, projectName, taskTitle) {
|
||||
if (!companyName || !projectName || !taskTitle) return;
|
||||
const co = safeName(companyName);
|
||||
const proj = safeName(projectName);
|
||||
const task = safeName(taskTitle);
|
||||
const base = `/Clients/${co}/Projects/${proj}/${task}`;
|
||||
const mkdirs = [
|
||||
`/Clients/${co}/`,
|
||||
`/Clients/${co}/Projects/`,
|
||||
`/Clients/${co}/Projects/${proj}/`,
|
||||
`/Clients/${co}/Projects/${proj}/00 Project Files/`,
|
||||
`${base}/`,
|
||||
`${base}/Request Info/`,
|
||||
`${base}/Old Books/`,
|
||||
];
|
||||
for (const dir of mkdirs) {
|
||||
await fbq('mkdir', dir);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createClientFolder(companyName) {
|
||||
if (!companyName) return;
|
||||
await fbq('mkdir', '/Clients/');
|
||||
await fbq('mkdir', `/Clients/${companyName}/`);
|
||||
}
|
||||
|
||||
export async function backfillClientFolders() {
|
||||
const { data } = await supabase.from('companies').select('name');
|
||||
if (!data?.length) return;
|
||||
await fbq('mkdir', '/Clients/');
|
||||
for (const company of data) {
|
||||
if (company.name) await fbq('mkdir', `/Clients/${company.name}/`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadFilesToRequestInfo(files, companyName, projectName, taskTitle, versionNumber = 0) {
|
||||
if (!files?.length || !companyName || !projectName || !taskTitle) return;
|
||||
|
||||
const co = safeName(companyName);
|
||||
const proj = safeName(projectName);
|
||||
const task = safeName(taskTitle);
|
||||
const rev = `R${String(versionNumber).padStart(2, '0')}`;
|
||||
const base = `/Clients/${co}/Projects/${proj}/${task}/Request Info/${rev}`;
|
||||
|
||||
const mkdirs = [
|
||||
`/Clients/${co}/`,
|
||||
`/Clients/${co}/Projects/`,
|
||||
`/Clients/${co}/Projects/${proj}/`,
|
||||
`/Clients/${co}/Projects/${proj}/${task}/`,
|
||||
`/Clients/${co}/Projects/${proj}/${task}/Request Info/`,
|
||||
`/Clients/${co}/Projects/${proj}/${task}/Working Files/`,
|
||||
`/Clients/${co}/Projects/${proj}/${task}/Old Books/`,
|
||||
`${base}/`,
|
||||
];
|
||||
for (const dir of mkdirs) {
|
||||
await fbq('mkdir', dir);
|
||||
}
|
||||
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) return;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = `${base}/${file.name}`;
|
||||
const fd = new FormData(); fd.append('file', file);
|
||||
await fetch(FBQ_FN, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'X-Operation': 'upload',
|
||||
'X-Path': filePath,
|
||||
},
|
||||
body: fd,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
async function callFolderSync(action, body) {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const accessToken = session?.access_token;
|
||||
if (!accessToken) return { ok: false, skipped: true, warning: 'No active session.' };
|
||||
|
||||
const response = await fetch(`/api/filebrowser?action=${encodeURIComponent(action)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || 'Folder sync failed.');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function ensureCompanyFolder({ companyId }) {
|
||||
if (!companyId) return { ok: false, skipped: true };
|
||||
return callFolderSync('ensure-company-folder', { companyId });
|
||||
}
|
||||
|
||||
export async function renameCompanyFolder({ companyId, oldName, newName }) {
|
||||
if (!companyId || !oldName || !newName) return { ok: false, skipped: true };
|
||||
return callFolderSync('rename-company-folder', { companyId, oldName, newName });
|
||||
}
|
||||
|
||||
export async function ensureProjectFolder({ projectId }) {
|
||||
if (!projectId) return { ok: false, skipped: true };
|
||||
return callFolderSync('ensure-project-folder', { projectId });
|
||||
}
|
||||
|
||||
export async function renameProjectFolder({ projectId, oldName, newName }) {
|
||||
if (!projectId || !oldName || !newName) return { ok: false, skipped: true };
|
||||
return callFolderSync('rename-project-folder', { projectId, oldName, newName });
|
||||
}
|
||||
|
||||
export async function ensureSubcontractorFolder({ profileId }) {
|
||||
if (!profileId) return { ok: false, skipped: true };
|
||||
return callFolderSync('ensure-profile-folder', { profileId });
|
||||
}
|
||||
|
||||
export async function renameSubcontractorFolder({ profileId, oldName, newName }) {
|
||||
if (!profileId || !oldName || !newName) return { ok: false, skipped: true };
|
||||
return callFolderSync('rename-profile-folder', { profileId, oldName, newName });
|
||||
}
|
||||
|
||||
export async function ensureRequestFolder({ projectId, taskTitle }) {
|
||||
if (!projectId || !taskTitle) return { ok: false, skipped: true };
|
||||
return callFolderSync('ensure-request-folder', { projectId, taskTitle });
|
||||
}
|
||||
|
||||
export async function uploadRequestFileToSurvey({ projectId, taskTitle, storagePath, fileName, bucket = 'submissions' }) {
|
||||
if (!projectId || !taskTitle || !storagePath || !fileName) return { ok: false, skipped: true };
|
||||
return callFolderSync('upload-request-file', { projectId, taskTitle, storagePath, fileName, bucket });
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export const CLIENT_APPROVED_OR_BETTER = new Set(['client_approved', 'invoiced', 'paid']);
|
||||
|
||||
export function isCompletedVersionEligible(task, versionNumber) {
|
||||
const taskVersion = Number(task?.current_version || 0);
|
||||
const version = Number(versionNumber || 0);
|
||||
if (version < taskVersion) return true;
|
||||
return version === taskVersion && CLIENT_APPROVED_OR_BETTER.has(task?.status);
|
||||
}
|
||||
|
||||
export function isInitialVersionEligible(task) {
|
||||
if (!task || task.invoiced) return false;
|
||||
return isCompletedVersionEligible(task, 0);
|
||||
}
|
||||
|
||||
export function getRevisionChargeQuantity(versionNumber) {
|
||||
const version = Number(versionNumber || 0);
|
||||
return version >= 2 ? 1 : 0;
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
import { supabase } from './supabase';
|
||||
import { ensureProjectFolder } from './folderSync';
|
||||
|
||||
function normalizeProjectName(name = '') {
|
||||
return String(name).trim().toLowerCase();
|
||||
}
|
||||
|
||||
function serializeSubmissionSigns(normalizedSigns = []) {
|
||||
return normalizedSigns.map((s, i) => ({
|
||||
sign_number: i + 1,
|
||||
sign_name: s.signName,
|
||||
sign_family: s.signFamily || null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function findOrCreateProject(companyId, projectName, knownProjects = []) {
|
||||
const normalized = normalizeProjectName(projectName);
|
||||
if (!companyId || !normalized) throw new Error('Project company and name are required.');
|
||||
@@ -31,7 +40,14 @@ export async function findOrCreateProject(companyId, projectName, knownProjects
|
||||
.select('id, name, company_id, status')
|
||||
.single();
|
||||
|
||||
if (!insertError && newProject) return newProject;
|
||||
if (!insertError && newProject) {
|
||||
try {
|
||||
await ensureProjectFolder({ projectId: newProject.id });
|
||||
} catch (folderError) {
|
||||
console.error('Project folder sync failed:', folderError);
|
||||
}
|
||||
return newProject;
|
||||
}
|
||||
|
||||
if (insertError?.code === '23505') {
|
||||
const { data: retryProjects, error: retryError } = await supabase
|
||||
@@ -88,6 +104,20 @@ export async function createInitialSubmissionForRequest({
|
||||
submittedBy,
|
||||
submittedByName,
|
||||
}) {
|
||||
const normalizedSignCount = Number.isFinite(Number(signCount)) ? Number(signCount) : null;
|
||||
const normalizedSigns = Array.isArray(signs)
|
||||
? signs
|
||||
.map((sign = {}) => ({
|
||||
signName: String(sign.signName || '').trim(),
|
||||
signFamily: String(sign.signFamily || '').trim(),
|
||||
}))
|
||||
.filter(sign => sign.signName || sign.signFamily)
|
||||
: [];
|
||||
|
||||
if ((normalizedSignCount || 0) > 0 && normalizedSigns.length !== normalizedSignCount) {
|
||||
throw new Error('Please fill in the sign information for each sign before submitting.');
|
||||
}
|
||||
|
||||
const { data: submission, error } = await supabase
|
||||
.from('submissions')
|
||||
.insert({
|
||||
@@ -98,7 +128,8 @@ export async function createInitialSubmissionForRequest({
|
||||
is_hot: isHot,
|
||||
service_type: serviceType,
|
||||
sign_family: signFamily || null,
|
||||
sign_count: signCount || null,
|
||||
sign_count: normalizedSignCount || null,
|
||||
signs: serializeSubmissionSigns(normalizedSigns),
|
||||
deadline: deadline || null,
|
||||
description,
|
||||
submitted_by: submittedBy,
|
||||
@@ -107,19 +138,7 @@ export async function createInitialSubmissionForRequest({
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (!error && submission) {
|
||||
if (signs?.length > 0) {
|
||||
await supabase.from('submission_signs').insert(
|
||||
signs.map((s, i) => ({
|
||||
submission_id: submission.id,
|
||||
sign_number: i + 1,
|
||||
sign_name: s.signName,
|
||||
sign_family: s.signFamily || null,
|
||||
}))
|
||||
);
|
||||
}
|
||||
return { submission, duplicate: false };
|
||||
}
|
||||
if (!error && submission) return { submission, duplicate: false };
|
||||
|
||||
if (error?.code === '23505' && requestKey) {
|
||||
const { data: existingSubmission, error: existingError } = await supabase
|
||||
@@ -128,7 +147,17 @@ export async function createInitialSubmissionForRequest({
|
||||
.eq('request_key', requestKey)
|
||||
.single();
|
||||
if (existingError) throw existingError;
|
||||
if (existingSubmission) return { submission: existingSubmission, duplicate: true };
|
||||
if (existingSubmission) {
|
||||
const existingSigns = Array.isArray(existingSubmission.signs) ? existingSubmission.signs : [];
|
||||
if (normalizedSigns.length > 0 && existingSigns.length === 0) {
|
||||
const { error: repairError } = await supabase
|
||||
.from('submissions')
|
||||
.update({ signs: serializeSubmissionSigns(normalizedSigns) })
|
||||
.eq('id', existingSubmission.id);
|
||||
if (repairError) throw repairError;
|
||||
}
|
||||
return { submission: existingSubmission, duplicate: true };
|
||||
}
|
||||
}
|
||||
|
||||
throw error || new Error('Failed to create submission.');
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export function mergeSubmissionDisplayNames(submissions = [], profiles = []) {
|
||||
const nameById = new Map(
|
||||
(profiles || [])
|
||||
.filter((profile) => profile?.id)
|
||||
.map((profile) => [profile.id, profile.name || ''])
|
||||
);
|
||||
|
||||
return (submissions || []).map((submission) => ({
|
||||
...submission,
|
||||
display_submitted_by_name: nameById.get(submission?.submitted_by) || submission?.submitted_by_name || '',
|
||||
}));
|
||||
}
|
||||
|
||||
export function getSubmissionDisplayName(submission) {
|
||||
return submission?.display_submitted_by_name || submission?.submitted_by_name || '';
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import { formatDateAtNoon } from './dates';
|
||||
|
||||
const W = 792;
|
||||
const H = 612;
|
||||
@@ -8,9 +9,7 @@ const DARK = [18, 18, 18];
|
||||
const HEADER_H = 64;
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(`${dateStr}T12:00:00`);
|
||||
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||
return formatDateAtNoon(dateStr, '-');
|
||||
}
|
||||
|
||||
async function blobToDataUrl(blob) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { sendEmail } from './email';
|
||||
|
||||
export async function sendTaskStatusUpdate({
|
||||
taskId,
|
||||
taskTitle,
|
||||
projectId,
|
||||
projectName = '',
|
||||
companyId,
|
||||
companyName = '',
|
||||
assignedProfileId,
|
||||
subject,
|
||||
headline,
|
||||
statusLabel,
|
||||
message,
|
||||
buttonLabel = 'View Task',
|
||||
includeTeam = false,
|
||||
includeAssigned = false,
|
||||
includeClient = false,
|
||||
includeProjectMembers = false,
|
||||
recipientStrategy = '',
|
||||
}) {
|
||||
if (!taskId || !taskTitle || !statusLabel || !message) return;
|
||||
return sendEmail('task_status_update', [], {
|
||||
taskId,
|
||||
taskTitle,
|
||||
projectId,
|
||||
projectName,
|
||||
companyId,
|
||||
companyName,
|
||||
assignedProfileId,
|
||||
subject,
|
||||
headline,
|
||||
statusLabel,
|
||||
message,
|
||||
buttonLabel,
|
||||
includeTeam,
|
||||
includeAssigned,
|
||||
includeClient,
|
||||
includeProjectMembers,
|
||||
recipientStrategy,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { popupSurfaceStyle } from './popupStyles';
|
||||
|
||||
export const TASK_TABLE_TH_STYLE = {
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
color: 'var(--text-muted)',
|
||||
textAlign: 'left',
|
||||
padding: '0 0 12px 5px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
verticalAlign: 'top',
|
||||
};
|
||||
|
||||
export const TASK_TABLE_TD_BASE = {
|
||||
padding: '5px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
export const TASK_MODAL_TITLE_STYLE = {
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
color: 'var(--text-secondary)',
|
||||
};
|
||||
|
||||
export const TASK_MODAL_LABEL_STYLE = {
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
export const TASK_MODAL_INPUT_STYLE = {
|
||||
fontSize: 13,
|
||||
padding: '0 5px',
|
||||
textAlign: 'left',
|
||||
lineHeight: 1,
|
||||
};
|
||||
|
||||
export const TASK_MODAL_BUTTON_STYLE = {
|
||||
lineHeight: 1,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
};
|
||||
|
||||
export const TASK_MODAL_SHELL_STYLE = {
|
||||
...popupSurfaceStyle,
|
||||
background: 'var(--card-bg)',
|
||||
width: 'min(720px, 96vw)',
|
||||
maxHeight: 'calc(100vh - 48px)',
|
||||
overflowY: 'auto',
|
||||
};
|
||||
|
||||
export const PROJECT_MODAL_SHELL_STYLE = {
|
||||
...popupSurfaceStyle,
|
||||
background: 'var(--card-bg)',
|
||||
width: 'min(434px, 100%)',
|
||||
maxHeight: 'calc(100vh - 48px)',
|
||||
overflowY: 'auto',
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from './taskDeadlines';
|
||||
import { serviceTypes } from '../data/mockData';
|
||||
|
||||
export const REJECT_NOTE_PREFIX = '[rejected-note]';
|
||||
export const REVIEW_SHADOW_PREFIX = '[review-shadow]';
|
||||
const SERVICE_TYPE_SET = new Set(serviceTypes);
|
||||
|
||||
function isRecognizedServiceType(value) {
|
||||
const normalized = String(value || '').trim();
|
||||
return normalized ? SERVICE_TYPE_SET.has(normalized) : false;
|
||||
}
|
||||
|
||||
export function parseRejectedNote(body = '') {
|
||||
if (!String(body).startsWith(REJECT_NOTE_PREFIX)) return null;
|
||||
const rest = String(body).slice(REJECT_NOTE_PREFIX.length);
|
||||
const [versionRaw, ...noteParts] = rest.split('::');
|
||||
const versionNumber = Number(versionRaw);
|
||||
if (!Number.isFinite(versionNumber)) return null;
|
||||
return { versionNumber, body: noteParts.join('::').trim() };
|
||||
}
|
||||
|
||||
export function encodeRejectedNote(versionNumber, body) {
|
||||
return `${REJECT_NOTE_PREFIX}${versionNumber}::${body}`;
|
||||
}
|
||||
|
||||
export function isReviewShadowSubmission(submission) {
|
||||
return String(submission?.description || '').startsWith(REVIEW_SHADOW_PREFIX);
|
||||
}
|
||||
|
||||
export function isReviewShadowDescription(description = '') {
|
||||
return String(description).startsWith(REVIEW_SHADOW_PREFIX);
|
||||
}
|
||||
|
||||
export function getVisibleTaskSubmissions(submissions = []) {
|
||||
return (submissions || []).filter(submission => !isReviewShadowSubmission(submission));
|
||||
}
|
||||
|
||||
export function getLatestVisibleSubmissionForVersion(taskId, submissions = [], versionNumber = 0) {
|
||||
return getVisibleTaskSubmissions(submissions)
|
||||
.filter(submission => submission?.task_id === taskId && (submission?.version_number || 0) === versionNumber)
|
||||
.sort((a, b) => new Date(b.submitted_at || 0).getTime() - new Date(a.submitted_at || 0).getTime())[0] || null;
|
||||
}
|
||||
|
||||
export function getLatestDeliveryForVersion(taskId, submissions = [], deliveries = [], versionNumber = 0) {
|
||||
const versionSubmissionIds = new Set(
|
||||
(submissions || [])
|
||||
.filter(submission => submission?.task_id === taskId && (submission?.version_number || 0) === versionNumber)
|
||||
.map(submission => submission.id)
|
||||
);
|
||||
if (versionSubmissionIds.size === 0) return null;
|
||||
return (deliveries || [])
|
||||
.filter(delivery => versionSubmissionIds.has(delivery?.submission_id))
|
||||
.sort((a, b) => new Date(b.sent_at || 0).getTime() - new Date(a.sent_at || 0).getTime())[0] || null;
|
||||
}
|
||||
|
||||
export function getTaskDerivedState(task, submissions = [], deliveries = []) {
|
||||
const taskSubs = (submissions || []).filter(submission => submission?.task_id === task?.id);
|
||||
const visibleTaskSubs = getVisibleTaskSubmissions(taskSubs);
|
||||
const currentVersion = getCurrentVersionForTask(task, taskSubs);
|
||||
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
||||
const latestVisibleSubmission = getLatestVisibleSubmissionForVersion(task?.id, taskSubs, currentVersion);
|
||||
const latestDelivery = getLatestDeliveryForVersion(task?.id, taskSubs, deliveries, currentVersion);
|
||||
const initialSubmission = visibleTaskSubs.find(submission => submission?.type === 'initial') || null;
|
||||
const canonicalServiceType =
|
||||
(isRecognizedServiceType(initialSubmission?.service_type) && initialSubmission?.service_type) ||
|
||||
visibleTaskSubs.find(submission => isRecognizedServiceType(submission?.service_type))?.service_type ||
|
||||
(isRecognizedServiceType(deadlineSource?.service_type) && deadlineSource?.service_type) ||
|
||||
'—';
|
||||
const deadline = deadlineSource?.deadline || latestVisibleSubmission?.deadline || null;
|
||||
const isHot = Boolean(deadlineSource?.is_hot || latestVisibleSubmission?.is_hot);
|
||||
const latestActivityAt = Math.max(
|
||||
latestVisibleSubmission?.submitted_at ? new Date(latestVisibleSubmission.submitted_at).getTime() : 0,
|
||||
latestDelivery?.sent_at ? new Date(latestDelivery.sent_at).getTime() : 0,
|
||||
task?.submitted_at ? new Date(task.submitted_at).getTime() : 0
|
||||
);
|
||||
|
||||
return {
|
||||
taskSubs,
|
||||
visibleTaskSubs,
|
||||
currentVersion,
|
||||
deadlineSource,
|
||||
latestVisibleSubmission,
|
||||
latestDelivery,
|
||||
initialSubmission,
|
||||
serviceType: canonicalServiceType,
|
||||
deadline,
|
||||
isHot,
|
||||
latestActivityAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export function getCurrentUserCompanyIds(currentUser) {
|
||||
return [
|
||||
...(currentUser?.companies?.map((company) => company?.company?.id || company?.id) || []),
|
||||
...(currentUser?.company_id ? [currentUser.company_id] : []),
|
||||
...(currentUser?.company?.id ? [currentUser.company.id] : []),
|
||||
].filter(Boolean).filter((value, index, list) => list.indexOf(value) === index);
|
||||
}
|
||||
|
||||
export async function resolveScopedWorkIds(currentUser, { isClient = false, isExternal = false } = {}) {
|
||||
let scopedCompanyIds = null;
|
||||
let scopedProjectIds = null;
|
||||
let scopedTaskIds = null;
|
||||
|
||||
if (isClient) {
|
||||
scopedCompanyIds = getCurrentUserCompanyIds(currentUser);
|
||||
if (scopedCompanyIds.length > 0) {
|
||||
const { data: projectRows } = await supabase
|
||||
.from('projects')
|
||||
.select('id, tasks(id)')
|
||||
.in('company_id', scopedCompanyIds);
|
||||
scopedProjectIds = (projectRows || []).map((project) => project.id).filter(Boolean);
|
||||
scopedTaskIds = (projectRows || [])
|
||||
.flatMap((project) => (project.tasks || []).map((task) => task.id))
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
scopedProjectIds = [];
|
||||
scopedTaskIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (isExternal) {
|
||||
const { data: memberRows } = await supabase
|
||||
.from('project_members')
|
||||
.select('project_id')
|
||||
.eq('profile_id', currentUser?.id);
|
||||
scopedProjectIds = (memberRows || []).map((row) => row.project_id).filter(Boolean);
|
||||
if (scopedProjectIds.length > 0) {
|
||||
const { data: taskRows } = await supabase
|
||||
.from('tasks')
|
||||
.select('id')
|
||||
.in('project_id', scopedProjectIds);
|
||||
scopedTaskIds = (taskRows || []).map((row) => row.id).filter(Boolean);
|
||||
} else {
|
||||
scopedTaskIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
return { scopedCompanyIds, scopedProjectIds, scopedTaskIds };
|
||||
}
|
||||
Reference in New Issue
Block a user