edits for suppliers & projects
This commit is contained in:
@@ -3,11 +3,169 @@ import { authenticate, authorize } from '../../shared/middleware/auth';
|
||||
import prisma from '../../config/database';
|
||||
import { ResponseFormatter } from '../../shared/utils/responseFormatter';
|
||||
import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
import { AppError } from '../../shared/middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
// Convert a "YYYY-MM-DD" or ISO string to a Date at UTC midnight.
|
||||
// Prisma's @db.Date columns require a real DateTime; a bare "YYYY-MM-DD"
|
||||
// in JSON gets parsed to "Invalid data" by the validator.
|
||||
const toDate = (value: unknown): Date | null | undefined => {
|
||||
if (value === null) return null;
|
||||
if (value === undefined || value === '') return undefined;
|
||||
if (value instanceof Date) return value;
|
||||
if (typeof value !== 'string') return undefined;
|
||||
// Already ISO?
|
||||
if (value.includes('T')) {
|
||||
const d = new Date(value);
|
||||
return isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
// Plain "YYYY-MM-DD"
|
||||
const d = new Date(`${value}T00:00:00Z`);
|
||||
return isNaN(d.getTime()) ? undefined : d;
|
||||
};
|
||||
|
||||
// Drop empty strings, normalize known date/number fields, and only keep keys
|
||||
// that actually exist on the Prisma Task model. Anything extra (e.g. `tags`)
|
||||
// would otherwise crash Prisma with "Unknown argument".
|
||||
const sanitizeTaskBody = (body: any, opts: { isUpdate?: boolean } = {}) => {
|
||||
const out: any = {};
|
||||
|
||||
const setIfPresent = (key: string, value: any) => {
|
||||
if (value === undefined) return;
|
||||
out[key] = value;
|
||||
};
|
||||
|
||||
// Strings: collapse '' -> null (update) or skip (create)
|
||||
const strField = (key: string) => {
|
||||
if (!(key in body)) return;
|
||||
const v = body[key];
|
||||
if (v === '' || v === null) {
|
||||
if (opts.isUpdate) out[key] = null;
|
||||
return;
|
||||
}
|
||||
if (typeof v === 'string') out[key] = v.trim();
|
||||
};
|
||||
|
||||
strField('title');
|
||||
strField('description');
|
||||
strField('projectId');
|
||||
strField('phaseId');
|
||||
strField('parentId');
|
||||
strField('assignedToId');
|
||||
|
||||
if ('status' in body && body.status) out.status = String(body.status);
|
||||
if ('priority' in body && body.priority) out.priority = String(body.priority);
|
||||
|
||||
if ('progress' in body) {
|
||||
const n = Number(body.progress);
|
||||
if (!Number.isNaN(n)) out.progress = Math.max(0, Math.min(100, Math.round(n)));
|
||||
}
|
||||
|
||||
if ('startDate' in body) {
|
||||
const d = toDate(body.startDate);
|
||||
if (d !== undefined) out.startDate = d;
|
||||
}
|
||||
if ('dueDate' in body) {
|
||||
const d = toDate(body.dueDate);
|
||||
if (d !== undefined) out.dueDate = d;
|
||||
}
|
||||
if ('completedDate' in body) {
|
||||
const d = toDate(body.completedDate);
|
||||
if (d !== undefined) out.completedDate = d;
|
||||
}
|
||||
|
||||
if ('estimatedHours' in body) {
|
||||
const v = body.estimatedHours;
|
||||
if (v === '' || v === null) {
|
||||
if (opts.isUpdate) out.estimatedHours = null;
|
||||
} else {
|
||||
const n = Number(v);
|
||||
if (!Number.isNaN(n) && n >= 0) out.estimatedHours = n;
|
||||
}
|
||||
}
|
||||
if ('actualHours' in body) {
|
||||
const v = body.actualHours;
|
||||
if (v === '' || v === null) {
|
||||
if (opts.isUpdate) out.actualHours = null;
|
||||
} else {
|
||||
const n = Number(v);
|
||||
if (!Number.isNaN(n) && n >= 0) out.actualHours = n;
|
||||
}
|
||||
}
|
||||
|
||||
// dependencies is Json? in the schema
|
||||
if ('dependencies' in body) out.dependencies = body.dependencies;
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
const sanitizeProjectBody = (body: any, opts: { isUpdate?: boolean } = {}) => {
|
||||
const out: any = {};
|
||||
|
||||
const strField = (key: string) => {
|
||||
if (!(key in body)) return;
|
||||
const v = body[key];
|
||||
if (v === '' || v === null) {
|
||||
if (opts.isUpdate) out[key] = null;
|
||||
return;
|
||||
}
|
||||
if (typeof v === 'string') out[key] = v.trim();
|
||||
};
|
||||
|
||||
// Required-ish strings
|
||||
if ('name' in body && body.name) out.name = String(body.name).trim();
|
||||
if ('type' in body && body.type) out.type = String(body.type);
|
||||
strField('description');
|
||||
strField('dealId');
|
||||
strField('clientId');
|
||||
|
||||
if ('status' in body && body.status) out.status = String(body.status);
|
||||
if ('priority' in body && body.priority) out.priority = String(body.priority);
|
||||
|
||||
if ('progress' in body) {
|
||||
const n = Number(body.progress);
|
||||
if (!Number.isNaN(n)) out.progress = Math.max(0, Math.min(100, Math.round(n)));
|
||||
}
|
||||
|
||||
if ('startDate' in body) {
|
||||
const d = toDate(body.startDate);
|
||||
if (d !== undefined) out.startDate = d;
|
||||
}
|
||||
if ('endDate' in body) {
|
||||
const d = toDate(body.endDate);
|
||||
if (d !== undefined) out.endDate = d;
|
||||
}
|
||||
if ('actualEndDate' in body) {
|
||||
const d = toDate(body.actualEndDate);
|
||||
if (d !== undefined) out.actualEndDate = d;
|
||||
}
|
||||
|
||||
const numField = (key: string) => {
|
||||
if (!(key in body)) return;
|
||||
const v = body[key];
|
||||
if (v === '' || v === null) {
|
||||
if (opts.isUpdate) out[key] = null;
|
||||
return;
|
||||
}
|
||||
const n = Number(v);
|
||||
if (!Number.isNaN(n)) out[key] = n;
|
||||
};
|
||||
numField('estimatedCost');
|
||||
numField('actualCost');
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Projects
|
||||
// ============================================================
|
||||
|
||||
router.get('/projects', authorize('projects', 'projects', 'read'), async (req, res, next) => {
|
||||
try {
|
||||
const projects = await prisma.project.findMany({
|
||||
@@ -37,6 +195,9 @@ router.get('/projects/:id', authorize('projects', 'projects', 'read'), async (re
|
||||
notes: true,
|
||||
},
|
||||
});
|
||||
if (!project) {
|
||||
throw new AppError(404, 'المشروع غير موجود - Project not found');
|
||||
}
|
||||
res.json(ResponseFormatter.success(project));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -45,18 +206,30 @@ router.get('/projects/:id', authorize('projects', 'projects', 'read'), async (re
|
||||
|
||||
router.post('/projects', authorize('projects', 'projects', 'create'), async (req, res, next) => {
|
||||
try {
|
||||
const data = sanitizeProjectBody(req.body, { isUpdate: false });
|
||||
|
||||
if (!data.name) {
|
||||
throw new AppError(400, 'اسم المشروع مطلوب - Project name is required');
|
||||
}
|
||||
if (!data.type) {
|
||||
throw new AppError(400, 'نوع المشروع مطلوب - Project type is required');
|
||||
}
|
||||
if (!data.startDate) {
|
||||
throw new AppError(400, 'تاريخ البدء مطلوب - Start date is required');
|
||||
}
|
||||
|
||||
const projectNumber = `PRJ-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`;
|
||||
const project = await prisma.project.create({
|
||||
data: { ...req.body, projectNumber },
|
||||
data: { ...data, projectNumber },
|
||||
});
|
||||
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'PROJECT',
|
||||
entityId: project.id,
|
||||
action: 'CREATE',
|
||||
userId: (req as any).user.id,
|
||||
});
|
||||
|
||||
|
||||
res.status(201).json(ResponseFormatter.success(project));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -65,33 +238,141 @@ router.post('/projects', authorize('projects', 'projects', 'create'), async (req
|
||||
|
||||
router.put('/projects/:id', authorize('projects', 'projects', 'update'), async (req, res, next) => {
|
||||
try {
|
||||
const data = sanitizeProjectBody(req.body, { isUpdate: true });
|
||||
const project = await prisma.project.update({
|
||||
where: { id: req.params.id },
|
||||
data: req.body,
|
||||
data,
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'PROJECT',
|
||||
entityId: project.id,
|
||||
action: 'UPDATE',
|
||||
userId: (req as any).user.id,
|
||||
});
|
||||
|
||||
res.json(ResponseFormatter.success(project));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete(
|
||||
'/projects/:id',
|
||||
authorize('projects', 'projects', 'delete'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
// Block delete when the project still has tasks / phases / etc.
|
||||
const counts = await prisma.project.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: {
|
||||
_count: {
|
||||
select: {
|
||||
tasks: true,
|
||||
phases: true,
|
||||
members: true,
|
||||
expenses: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!counts) {
|
||||
throw new AppError(404, 'المشروع غير موجود - Project not found');
|
||||
}
|
||||
|
||||
const c = counts._count;
|
||||
if (c.tasks > 0 || c.phases > 0 || c.expenses > 0) {
|
||||
throw new AppError(
|
||||
409,
|
||||
`لا يمكن حذف المشروع - يحتوي على ${c.tasks} مهمة، ${c.phases} مرحلة، ${c.expenses} مصروف. احذفها أولاً.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Detach members (safe - they're just join rows) then delete
|
||||
if (c.members > 0) {
|
||||
await prisma.projectMember.deleteMany({ where: { projectId: req.params.id } });
|
||||
}
|
||||
|
||||
await prisma.project.delete({ where: { id: req.params.id } });
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'PROJECT',
|
||||
entityId: req.params.id,
|
||||
action: 'DELETE',
|
||||
userId: (req as any).user.id,
|
||||
});
|
||||
|
||||
res.json(ResponseFormatter.success({ id: req.params.id }, 'تم حذف المشروع - Project deleted'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Tasks
|
||||
// ============================================================
|
||||
|
||||
router.get('/tasks', authorize('projects', 'tasks', 'read'), async (req, res, next) => {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1);
|
||||
const pageSize = Math.min(
|
||||
100,
|
||||
Math.max(1, parseInt(String(req.query.pageSize || '20'), 10) || 20),
|
||||
);
|
||||
|
||||
const where: any = {};
|
||||
if (req.query.projectId) where.projectId = req.query.projectId;
|
||||
if (req.query.assignedToId) where.assignedToId = req.query.assignedToId;
|
||||
if (req.query.status) where.status = req.query.status;
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where,
|
||||
if (req.query.priority) where.priority = req.query.priority;
|
||||
|
||||
if (req.query.search) {
|
||||
const q = String(req.query.search);
|
||||
where.OR = [
|
||||
{ title: { contains: q, mode: 'insensitive' } },
|
||||
{ description: { contains: q, mode: 'insensitive' } },
|
||||
{ taskNumber: { contains: q, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const [tasks, total] = await Promise.all([
|
||||
prisma.task.findMany({
|
||||
where,
|
||||
include: {
|
||||
project: true,
|
||||
assignedTo: { select: { id: true, email: true, username: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.task.count({ where }),
|
||||
]);
|
||||
|
||||
res.json(ResponseFormatter.paginated(tasks, total, page, pageSize));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tasks/:id', authorize('projects', 'tasks', 'read'), async (req, res, next) => {
|
||||
try {
|
||||
const task = await prisma.task.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
project: true,
|
||||
assignedTo: { select: { email: true, username: true } },
|
||||
assignedTo: { select: { id: true, email: true, username: true } },
|
||||
phase: true,
|
||||
parent: true,
|
||||
children: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(ResponseFormatter.success(tasks));
|
||||
if (!task) {
|
||||
throw new AppError(404, 'المهمة غير موجودة - Task not found');
|
||||
}
|
||||
res.json(ResponseFormatter.success(task));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -99,12 +380,18 @@ router.get('/tasks', authorize('projects', 'tasks', 'read'), async (req, res, ne
|
||||
|
||||
router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res, next) => {
|
||||
try {
|
||||
const data = sanitizeTaskBody(req.body, { isUpdate: false });
|
||||
|
||||
if (!data.title) {
|
||||
throw new AppError(400, 'عنوان المهمة مطلوب - Task title is required');
|
||||
}
|
||||
|
||||
const taskNumber = `TSK-${new Date().getFullYear()}-${Date.now().toString().slice(-6)}`;
|
||||
const task = await prisma.task.create({
|
||||
data: { ...req.body, taskNumber },
|
||||
data: { ...data, taskNumber },
|
||||
include: { project: true, assignedTo: true },
|
||||
});
|
||||
|
||||
|
||||
// Create notification for assigned user
|
||||
if (task.assignedToId) {
|
||||
await prisma.notification.create({
|
||||
@@ -118,7 +405,14 @@ router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'TASK',
|
||||
entityId: task.id,
|
||||
action: 'CREATE',
|
||||
userId: (req as any).user.id,
|
||||
});
|
||||
|
||||
res.status(201).json(ResponseFormatter.success(task));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -127,15 +421,41 @@ router.post('/tasks', authorize('projects', 'tasks', 'create'), async (req, res,
|
||||
|
||||
router.put('/tasks/:id', authorize('projects', 'tasks', 'update'), async (req, res, next) => {
|
||||
try {
|
||||
const data = sanitizeTaskBody(req.body, { isUpdate: true });
|
||||
const task = await prisma.task.update({
|
||||
where: { id: req.params.id },
|
||||
data: req.body,
|
||||
data,
|
||||
include: { project: true, assignedTo: true },
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'TASK',
|
||||
entityId: task.id,
|
||||
action: 'UPDATE',
|
||||
userId: (req as any).user.id,
|
||||
});
|
||||
|
||||
res.json(ResponseFormatter.success(task));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
router.delete('/tasks/:id', authorize('projects', 'tasks', 'delete'), async (req, res, next) => {
|
||||
try {
|
||||
await prisma.task.delete({ where: { id: req.params.id } });
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'TASK',
|
||||
entityId: req.params.id,
|
||||
action: 'DELETE',
|
||||
userId: (req as any).user.id,
|
||||
});
|
||||
|
||||
res.json(ResponseFormatter.success({ id: req.params.id }, 'تم حذف المهمة - Task deleted'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -194,6 +194,19 @@ export class TendersController {
|
||||
}
|
||||
}
|
||||
|
||||
async getAssignableEmployees(
|
||||
_req: AuthRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const employees = await tendersService.getAssignableEmployees();
|
||||
res.json(ResponseFormatter.success(employees));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadTenderAttachment(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
|
||||
@@ -56,6 +56,16 @@ router.get(
|
||||
tendersController.getDirectiveTypeValues
|
||||
);
|
||||
|
||||
// Minimal employee list for directive assignee dropdown.
|
||||
// Guarded by directive-create permission so users who can issue directives
|
||||
// can populate the dropdown WITHOUT being granted hr:employees:read
|
||||
// (which would expose salaries, national IDs, and other sensitive HR data).
|
||||
router.get(
|
||||
'/assignable-employees',
|
||||
authorize('tenders', 'directives', 'create'),
|
||||
tendersController.getAssignableEmployees
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/check-duplicates',
|
||||
authorize('tenders', 'tenders', 'create'),
|
||||
|
||||
@@ -670,6 +670,27 @@ private getEffectiveTenderStatus(tender: {
|
||||
return [...DIRECTIVE_TYPE_VALUES];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a minimal employee list (id + names only) for filling the
|
||||
* directive assignee dropdown. Intentionally does NOT include salary,
|
||||
* national ID, passport, email, phone, or any other sensitive HR fields
|
||||
* so this endpoint can be exposed to anyone with directive-create
|
||||
* permission without leaking HR data.
|
||||
*/
|
||||
async getAssignableEmployees() {
|
||||
return prisma.employee.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
firstNameAr: true,
|
||||
lastNameAr: true,
|
||||
},
|
||||
orderBy: [{ firstName: 'asc' }, { lastName: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async convertToDeal(
|
||||
tenderId: string,
|
||||
data: { contactId: string; pipelineId: string; ownerId?: string },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import type { ReactNode } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { ArrowLeft, Building2, Calendar, CircleDollarSign, Copy, FileText, Globe, Landmark, Mail, MapPin, Phone, Star, Tag, Truck, XCircle } from 'lucide-react'
|
||||
import { ArrowLeft, Building2, Calendar, CircleDollarSign, Copy, FileText, Globe, Landmark, Mail, MapPin, Phone, Star, Tag, Truck, Users, XCircle } from 'lucide-react'
|
||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||
import { suppliersAPI, Supplier } from '@/lib/api/suppliers'
|
||||
@@ -102,6 +102,21 @@ function SupplierDetailContent() {
|
||||
const supplierCategoryLabels = getSupplierCategoryLabels(supplier)
|
||||
const categoryLabels = supplierCategoryLabels
|
||||
|
||||
// Additional contact persons (stored as a JSON array in customFields).
|
||||
// Safe to read even if absent / malformed: filter to objects with a name.
|
||||
const additionalContacts: Array<{
|
||||
name?: string
|
||||
position?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
mobile?: string
|
||||
notes?: string
|
||||
}> = Array.isArray(customFields.additionalContacts)
|
||||
? customFields.additionalContacts.filter(
|
||||
(c: any) => c && typeof c === 'object' && typeof c.name === 'string' && c.name.trim() !== ''
|
||||
)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
@@ -121,6 +136,67 @@ function SupplierDetailContent() {
|
||||
<div className="lg:col-span-1"><div className="bg-white rounded-xl shadow-sm border p-6"><div className="text-center mb-6"><div className="h-32 w-32 rounded-full bg-gradient-to-br from-emerald-600 to-emerald-700 flex items-center justify-center text-white text-4xl font-bold mx-auto mb-4">{supplierName.charAt(0).toUpperCase()}</div><h2 className="text-xl font-bold text-gray-900">{supplierName}</h2>{supplier.companyNameAr && <p className="text-gray-600 mt-1" dir="rtl">{supplier.companyNameAr}</p>}{customFields.supplierCode && <p className="text-sm text-gray-500 mt-2 font-mono">{customFields.supplierCode}</p>}</div><div className="mb-6 pb-6 border-b"><label className="text-sm font-medium text-gray-700 block mb-2">Rating</label>{renderStars(supplier.rating)}</div><div className="space-y-2">{supplier.email && <button onClick={() => copyToClipboard(supplier.email!, 'Email')} className="w-full flex items-center gap-3 px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50"><Mail className="h-5 w-5 text-gray-600" /><span className="flex-1 text-left text-sm">{supplier.email}</span><Copy className={`h-4 w-4 ${copiedField === 'Email' ? 'text-green-600' : 'text-gray-400'}`} /></button>}{(supplier.phone || supplier.mobile) && <button onClick={() => copyToClipboard((supplier.phone || supplier.mobile)!, 'Phone')} className="w-full flex items-center gap-3 px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50"><Phone className="h-5 w-5 text-gray-600" /><span className="flex-1 text-left text-sm">{supplier.phone || supplier.mobile}</span><Copy className={`h-4 w-4 ${copiedField === 'Phone' ? 'text-green-600' : 'text-gray-400'}`} /></button>}{supplier.website && <a href={supplier.website.startsWith('http') ? supplier.website : `https://${supplier.website}`} target="_blank" rel="noopener noreferrer" className="w-full flex items-center gap-3 px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50"><Globe className="h-5 w-5 text-gray-600" /><span className="flex-1 text-left text-sm">{supplier.website}</span></a>}</div><div className="mt-6 pt-6 border-t space-y-2"><div className="flex items-center gap-2 text-sm text-gray-600"><Calendar className="h-4 w-4" /><span>Created: {new Date(supplier.createdAt).toLocaleDateString()}</span></div><div className="space-y-2"><div className="flex items-center gap-2 text-sm text-gray-600"><Tag className="h-4 w-4" /><span>Categories</span></div><CategoryBadges labels={categoryLabels} /></div></div></div></div>
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<InfoCard icon={Building2} title="Supplier Information"><dl className="grid grid-cols-1 md:grid-cols-2 gap-4"><Field label="Supplier Name" value={supplierName} /><Field label="Arabic Name" value={supplier.companyNameAr} /><Field label="Contact Person" value={contactPerson} /><Field label="Contact Position" value={customFields.contactPosition} /><Field label="Supplier Code" value={customFields.supplierCode} mono /><div><dt className="text-sm font-medium text-gray-500">Supplier Categories</dt><dd className="mt-2"><CategoryBadges labels={categoryLabels} /></dd></div></dl></InfoCard>
|
||||
|
||||
{additionalContacts.length > 0 && (
|
||||
<InfoCard icon={Users} title={`أشخاص تواصل إضافيين (${additionalContacts.length})`}>
|
||||
<div className="space-y-4">
|
||||
{additionalContacts.map((contact, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border border-gray-200 bg-gray-50/50 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-full bg-emerald-100 flex items-center justify-center text-emerald-700 font-semibold text-sm">
|
||||
{(contact.name || '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{contact.name}</p>
|
||||
{contact.position && (
|
||||
<p className="text-xs text-gray-500">{contact.position}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{contact.email && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail className="h-4 w-4 text-gray-400 shrink-0" />
|
||||
<a
|
||||
href={`mailto:${contact.email}`}
|
||||
className="text-emerald-700 hover:underline truncate"
|
||||
>
|
||||
{contact.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{contact.phone && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Phone className="h-4 w-4 text-gray-400 shrink-0" />
|
||||
<span className="text-gray-900" dir="ltr">{contact.phone}</span>
|
||||
<span className="text-xs text-gray-500">(الهاتف)</span>
|
||||
</div>
|
||||
)}
|
||||
{contact.mobile && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Phone className="h-4 w-4 text-gray-400 shrink-0" />
|
||||
<span className="text-gray-900" dir="ltr">{contact.mobile}</span>
|
||||
<span className="text-xs text-gray-500">(الموبايل)</span>
|
||||
</div>
|
||||
)}
|
||||
{contact.notes && (
|
||||
<div className="md:col-span-2 flex items-start gap-2 text-sm">
|
||||
<FileText className="h-4 w-4 text-gray-400 shrink-0 mt-0.5" />
|
||||
<span className="text-gray-700 whitespace-pre-wrap">{contact.notes}</span>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</InfoCard>
|
||||
)}
|
||||
<InfoCard icon={Landmark} title="Legal & Financial"><dl className="grid grid-cols-1 md:grid-cols-2 gap-4"><Field label="Tax Number" value={supplier.taxNumber} mono /><Field label="Commercial Register" value={supplier.commercialRegister} mono /><Field label="Payment Terms" value={customFields.paymentTerms} /><Field label="Bank Name" value={customFields.bankName} /><Field label="Bank Account / IBAN" value={customFields.bankAccount} mono /></dl>{!supplier.taxNumber && !supplier.commercialRegister && !customFields.paymentTerms && !customFields.bankName && !customFields.bankAccount && <div className="text-center py-6 text-gray-500"><CircleDollarSign className="h-10 w-10 mx-auto mb-2 text-gray-300" /><p>No financial information available</p></div>}</InfoCard>
|
||||
<InfoCard icon={MapPin} title="Address & Notes"><dl className="grid grid-cols-1 md:grid-cols-2 gap-4"><Field label="Address" value={supplier.address} /><Field label="City" value={supplier.city} /><Field label="Country" value={supplier.country} /><Field label="Postal Code" value={supplier.postalCode} /></dl>{customFields.notes && <div className="mt-6 pt-6 border-t"><div className="flex items-center gap-2 mb-2"><FileText className="h-4 w-4 text-gray-500" /><h4 className="font-medium text-gray-900">Notes</h4></div><p className="text-sm text-gray-700 whitespace-pre-wrap">{customFields.notes}</p></div>}</InfoCard>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,24 @@ import SupplierCategorySelector from '@/components/suppliers/SupplierCategorySel
|
||||
import { suppliersAPI, Supplier, SupplierFilters, CreateSupplierData, UpdateSupplierData, SupplierStats } from '@/lib/api/suppliers'
|
||||
import { DEFAULT_SUPPLIER_CATEGORIES, isSupplierSystemCategoryName, uniqueSupplierCategories } from '@/lib/supplierCategories'
|
||||
|
||||
type SupplierAdditionalContact = {
|
||||
name: string
|
||||
position: string
|
||||
email: string
|
||||
phone: string
|
||||
mobile: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
const emptyAdditionalContact = (): SupplierAdditionalContact => ({
|
||||
name: '',
|
||||
position: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
mobile: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
type SupplierFormState = {
|
||||
companyName: string
|
||||
companyNameAr: string
|
||||
@@ -36,6 +54,7 @@ type SupplierFormState = {
|
||||
tags: string
|
||||
rating: number
|
||||
status: string
|
||||
additionalContacts: SupplierAdditionalContact[]
|
||||
}
|
||||
|
||||
function getSupplierCategoryLabels(supplier: Supplier): string[] {
|
||||
@@ -52,6 +71,20 @@ function getSupplierCategoryLabels(supplier: Supplier): string[] {
|
||||
|
||||
const buildInitialForm = (supplier?: Supplier): SupplierFormState => {
|
||||
const customFields = supplier?.customFields || {}
|
||||
|
||||
// Hydrate additional contacts from customFields, tolerating bad/missing data.
|
||||
const rawAdditional = Array.isArray(customFields.additionalContacts)
|
||||
? customFields.additionalContacts
|
||||
: []
|
||||
const additionalContacts: SupplierAdditionalContact[] = rawAdditional.map((c: any) => ({
|
||||
name: typeof c?.name === 'string' ? c.name : '',
|
||||
position: typeof c?.position === 'string' ? c.position : '',
|
||||
email: typeof c?.email === 'string' ? c.email : '',
|
||||
phone: typeof c?.phone === 'string' ? c.phone : '',
|
||||
mobile: typeof c?.mobile === 'string' ? c.mobile : '',
|
||||
notes: typeof c?.notes === 'string' ? c.notes : '',
|
||||
}))
|
||||
|
||||
return {
|
||||
companyName: supplier?.companyName || supplier?.name || '',
|
||||
companyNameAr: supplier?.companyNameAr || '',
|
||||
@@ -75,6 +108,7 @@ const buildInitialForm = (supplier?: Supplier): SupplierFormState => {
|
||||
tags: supplier?.tags?.join(', ') || '',
|
||||
rating: supplier?.rating || 0,
|
||||
status: supplier?.status || 'ACTIVE',
|
||||
additionalContacts,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,10 +145,49 @@ function SupplierForm({ supplier, submitting, availableCategories, onCancel, onS
|
||||
|
||||
const updateField = (field: keyof SupplierFormState, value: string | number) => setForm((prev) => ({ ...prev, [field]: value }))
|
||||
const optional = (value: string) => value.trim() || undefined
|
||||
|
||||
// Helpers for additional contact persons.
|
||||
const updateAdditionalContact = (
|
||||
index: number,
|
||||
field: keyof SupplierAdditionalContact,
|
||||
value: string
|
||||
) => {
|
||||
setForm((prev) => {
|
||||
const next = prev.additionalContacts.map((contact, i) =>
|
||||
i === index ? { ...contact, [field]: value } : contact
|
||||
)
|
||||
return { ...prev, additionalContacts: next }
|
||||
})
|
||||
}
|
||||
const addAdditionalContact = () => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
additionalContacts: [...prev.additionalContacts, emptyAdditionalContact()],
|
||||
}))
|
||||
}
|
||||
const removeAdditionalContact = (index: number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
additionalContacts: prev.additionalContacts.filter((_, i) => i !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!form.companyName.trim() && !form.name.trim()) errors.companyName = 'اسم المورد أو مسؤول التواصل مطلوب'
|
||||
if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) errors.email = 'صيغة البريد الإلكتروني غير صحيحة'
|
||||
|
||||
form.additionalContacts.forEach((contact, index) => {
|
||||
// A row is "filled" if any field has content; once filled, name and a valid email become rules.
|
||||
const hasAnyValue = Object.values(contact).some((value) => value.trim() !== '')
|
||||
if (hasAnyValue && !contact.name.trim()) {
|
||||
errors[`additionalContact_${index}_name`] = 'اسم شخص التواصل مطلوب'
|
||||
}
|
||||
if (contact.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact.email)) {
|
||||
errors[`additionalContact_${index}_email`] = 'صيغة البريد الإلكتروني غير صحيحة'
|
||||
}
|
||||
})
|
||||
|
||||
setFormErrors(errors)
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
@@ -125,6 +198,19 @@ function SupplierForm({ supplier, submitting, availableCategories, onCancel, onS
|
||||
const companyName = form.companyName.trim() || form.name.trim()
|
||||
const contactName = form.name.trim() || companyName
|
||||
const supplierCategories = uniqueSupplierCategories(form.supplierCategories)
|
||||
|
||||
// Drop entirely-empty rows; trim every field on the rest.
|
||||
const cleanedAdditionalContacts = form.additionalContacts
|
||||
.map((contact) => ({
|
||||
name: contact.name.trim(),
|
||||
position: contact.position.trim(),
|
||||
email: contact.email.trim(),
|
||||
phone: contact.phone.trim(),
|
||||
mobile: contact.mobile.trim(),
|
||||
notes: contact.notes.trim(),
|
||||
}))
|
||||
.filter((contact) => Object.values(contact).some((value) => value !== ''))
|
||||
|
||||
const payload: any = {
|
||||
name: contactName,
|
||||
companyName,
|
||||
@@ -150,6 +236,7 @@ function SupplierForm({ supplier, submitting, availableCategories, onCancel, onS
|
||||
bankAccount: form.bankAccount.trim(),
|
||||
contactPosition: form.contactPosition.trim(),
|
||||
notes: form.notes.trim(),
|
||||
additionalContacts: cleanedAdditionalContacts,
|
||||
},
|
||||
}
|
||||
if (isEdit) payload.status = form.status
|
||||
@@ -165,7 +252,7 @@ function SupplierForm({ supplier, submitting, availableCategories, onCancel, onS
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">اسم المورد / الشركة <span className="text-red-500">*</span></label><input value={form.companyName} onChange={(e) => updateField('companyName', e.target.value)} className={inputClass} placeholder="اسم الشركة أو المورد" />{formErrors.companyName && <p className="text-red-500 text-xs mt-1">{formErrors.companyName}</p>}</div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">اسم المورد بالعربي</label><input value={form.companyNameAr} onChange={(e) => updateField('companyNameAr', e.target.value)} className={inputClass} dir="rtl" /></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">مسؤول التواصل</label><input value={form.name} onChange={(e) => updateField('name', e.target.value)} className={inputClass} placeholder="اسم الشخص المسؤول" /></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">مسؤول التواصل الرئيسي</label><input value={form.name} onChange={(e) => updateField('name', e.target.value)} className={inputClass} placeholder="اسم الشخص المسؤول" /></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">الصفة / المنصب</label><input value={form.contactPosition} onChange={(e) => updateField('contactPosition', e.target.value)} className={inputClass} placeholder="Sales Manager, Accountant..." /></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,6 +266,144 @@ function SupplierForm({ supplier, submitting, availableCategories, onCancel, onS
|
||||
<div className="mt-4"><label className="block text-sm font-medium text-gray-700 mb-2">التقييم</label><div className="flex items-center gap-2">{[1, 2, 3, 4, 5].map((star) => <button key={star} type="button" onClick={() => updateField('rating', star)} className="focus:outline-none"><Star className={`h-7 w-7 ${star <= form.rating ? 'fill-yellow-400 text-yellow-400' : 'text-gray-300 hover:text-yellow-200'}`} /></button>)}{form.rating > 0 && <button type="button" onClick={() => updateField('rating', 0)} className="text-sm text-gray-500 hover:text-gray-700">مسح التقييم</button>}</div></div>
|
||||
</div>
|
||||
<div className="pt-6 border-t"><h3 className="text-lg font-semibold text-gray-900 mb-4">بيانات التواصل</h3><div className="grid grid-cols-1 md:grid-cols-2 gap-4"><div><label className="block text-sm font-medium text-gray-700 mb-1">البريد الإلكتروني</label><input type="email" value={form.email} onChange={(e) => updateField('email', e.target.value)} className={inputClass} />{formErrors.email && <p className="text-red-500 text-xs mt-1">{formErrors.email}</p>}</div><div><label className="block text-sm font-medium text-gray-700 mb-1">الهاتف</label><input value={form.phone} onChange={(e) => updateField('phone', e.target.value)} className={inputClass} /></div><div><label className="block text-sm font-medium text-gray-700 mb-1">الموبايل</label><input value={form.mobile} onChange={(e) => updateField('mobile', e.target.value)} className={inputClass} /></div><div><label className="block text-sm font-medium text-gray-700 mb-1">الموقع الإلكتروني</label><input value={form.website} onChange={(e) => updateField('website', e.target.value)} className={inputClass} /></div></div></div>
|
||||
|
||||
<div className="pt-6 border-t">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">أشخاص تواصل إضافيين</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addAdditionalContact}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-emerald-50 text-emerald-700 border border-emerald-200 rounded-lg hover:bg-emerald-100"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
إضافة شخص تواصل
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{form.additionalContacts.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">
|
||||
لم تتم إضافة أشخاص تواصل إضافيين. اضغط "إضافة شخص تواصل" لإضافة المزيد.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{form.additionalContacts.map((contact, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-xl border border-gray-200 bg-gray-50/50 p-4 relative"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700">
|
||||
شخص تواصل #{index + 1}
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAdditionalContact(index)}
|
||||
className="flex items-center gap-1 text-sm text-red-600 hover:text-red-700"
|
||||
aria-label="حذف شخص التواصل"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
الاسم <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={contact.name}
|
||||
onChange={(e) =>
|
||||
updateAdditionalContact(index, 'name', e.target.value)
|
||||
}
|
||||
className={inputClass}
|
||||
placeholder="اسم الشخص"
|
||||
/>
|
||||
{formErrors[`additionalContact_${index}_name`] && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
{formErrors[`additionalContact_${index}_name`]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
الصفة / المنصب
|
||||
</label>
|
||||
<input
|
||||
value={contact.position}
|
||||
onChange={(e) =>
|
||||
updateAdditionalContact(index, 'position', e.target.value)
|
||||
}
|
||||
className={inputClass}
|
||||
placeholder="Sales Manager, Accountant..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
البريد الإلكتروني
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={contact.email}
|
||||
onChange={(e) =>
|
||||
updateAdditionalContact(index, 'email', e.target.value)
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
{formErrors[`additionalContact_${index}_email`] && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
{formErrors[`additionalContact_${index}_email`]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
الهاتف
|
||||
</label>
|
||||
<input
|
||||
value={contact.phone}
|
||||
onChange={(e) =>
|
||||
updateAdditionalContact(index, 'phone', e.target.value)
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
الموبايل
|
||||
</label>
|
||||
<input
|
||||
value={contact.mobile}
|
||||
onChange={(e) =>
|
||||
updateAdditionalContact(index, 'mobile', e.target.value)
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
ملاحظات
|
||||
</label>
|
||||
<input
|
||||
value={contact.notes}
|
||||
onChange={(e) =>
|
||||
updateAdditionalContact(index, 'notes', e.target.value)
|
||||
}
|
||||
className={inputClass}
|
||||
placeholder="ملاحظات إضافية..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-6 border-t"><h3 className="text-lg font-semibold text-gray-900 mb-4">بيانات مالية وقانونية</h3><div className="grid grid-cols-1 md:grid-cols-2 gap-4"><div><label className="block text-sm font-medium text-gray-700 mb-1">الرقم الضريبي</label><input value={form.taxNumber} onChange={(e) => updateField('taxNumber', e.target.value)} className={`${inputClass} font-mono`} /></div><div><label className="block text-sm font-medium text-gray-700 mb-1">السجل التجاري</label><input value={form.commercialRegister} onChange={(e) => updateField('commercialRegister', e.target.value)} className={`${inputClass} font-mono`} /></div><div><label className="block text-sm font-medium text-gray-700 mb-1">شروط الدفع</label><input value={form.paymentTerms} onChange={(e) => updateField('paymentTerms', e.target.value)} className={inputClass} placeholder="Net 30, Cash..." /></div><div><label className="block text-sm font-medium text-gray-700 mb-1">اسم البنك</label><input value={form.bankName} onChange={(e) => updateField('bankName', e.target.value)} className={inputClass} /></div><div className="md:col-span-2"><label className="block text-sm font-medium text-gray-700 mb-1">الحساب البنكي / IBAN</label><input value={form.bankAccount} onChange={(e) => updateField('bankAccount', e.target.value)} className={`${inputClass} font-mono`} /></div></div></div>
|
||||
<div className="pt-6 border-t"><h3 className="text-lg font-semibold text-gray-900 mb-4">العنوان والملاحظات</h3><div className="grid grid-cols-1 md:grid-cols-2 gap-4"><div className="md:col-span-2"><label className="block text-sm font-medium text-gray-700 mb-1">العنوان</label><input value={form.address} onChange={(e) => updateField('address', e.target.value)} className={inputClass} /></div><div><label className="block text-sm font-medium text-gray-700 mb-1">المدينة</label><input value={form.city} onChange={(e) => updateField('city', e.target.value)} className={inputClass} /></div><div><label className="block text-sm font-medium text-gray-700 mb-1">الدولة</label><input value={form.country} onChange={(e) => updateField('country', e.target.value)} className={inputClass} /></div><div className="md:col-span-2"><label className="block text-sm font-medium text-gray-700 mb-1">Tags</label><input value={form.tags} onChange={(e) => updateField('tags', e.target.value)} className={inputClass} placeholder="tag1, tag2" /></div><div className="md:col-span-2"><label className="block text-sm font-medium text-gray-700 mb-1">ملاحظات</label><textarea value={form.notes} onChange={(e) => updateField('notes', e.target.value)} className={inputClass} rows={3} /></div></div></div>
|
||||
<div className="flex items-center justify-end gap-3 pt-6 border-t"><button type="button" onClick={onCancel} className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50" disabled={submitting}>إلغاء</button><button type="submit" className="flex items-center gap-2 px-6 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50" disabled={submitting}>{submitting ? <><Loader2 className="h-4 w-4 animate-spin" /> جاري الحفظ...</> : (isEdit ? 'تحديث المورد' : 'إنشاء المورد')}</button></div>
|
||||
|
||||
@@ -25,7 +25,6 @@ import Modal from '@/components/Modal'
|
||||
import { tendersAPI, Tender, TenderDirective, CreateDirectiveData } from '@/lib/api/tenders'
|
||||
import { contactsAPI } from '@/lib/api/contacts'
|
||||
import { pipelinesAPI } from '@/lib/api/pipelines'
|
||||
import { employeesAPI } from '@/lib/api/employees'
|
||||
import { useLanguage } from '@/contexts/LanguageContext'
|
||||
|
||||
const DIRECTIVE_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -134,9 +133,12 @@ function TenderDetailContent() {
|
||||
|
||||
useEffect(() => {
|
||||
if (showDirectiveModal || showConvertModal) {
|
||||
employeesAPI
|
||||
.getAll({ status: 'ACTIVE', pageSize: 500 })
|
||||
.then((r: any) => setEmployees(r.employees || []))
|
||||
// Use the directive-scoped employee list so non-HR users (with
|
||||
// tenders:directives:create) can populate this dropdown without
|
||||
// being granted hr:employees:read (which would leak salaries etc.).
|
||||
tendersAPI
|
||||
.getAssignableEmployees()
|
||||
.then((list) => setEmployees(list))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
|
||||
@@ -115,17 +115,49 @@ export interface Project {
|
||||
name: string
|
||||
nameAr?: string
|
||||
description?: string
|
||||
type?: string
|
||||
status: string
|
||||
priority?: string
|
||||
progress?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
budget?: number
|
||||
estimatedCost?: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateProjectData {
|
||||
name: string
|
||||
description?: string
|
||||
type: string
|
||||
startDate: string
|
||||
endDate?: string
|
||||
estimatedCost?: number
|
||||
status?: string
|
||||
priority?: string
|
||||
progress?: number
|
||||
}
|
||||
|
||||
export interface UpdateProjectData extends Partial<CreateProjectData> {}
|
||||
|
||||
export const projectsAPI = {
|
||||
getAll: async (): Promise<Project[]> => {
|
||||
const response = await api.get('/projects/projects')
|
||||
return response.data.data
|
||||
}
|
||||
},
|
||||
|
||||
create: async (data: CreateProjectData): Promise<Project> => {
|
||||
const response = await api.post('/projects/projects', data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateProjectData): Promise<Project> => {
|
||||
const response = await api.put(`/projects/projects/${id}`, data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/projects/projects/${id}`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -209,4 +209,17 @@ export const tendersAPI = {
|
||||
const response = await api.get('/tenders/directive-type-values')
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
// Minimal employee list (id + names only) safe to call without
|
||||
// hr:employees:read. Used only to populate the directive assignee dropdown.
|
||||
getAssignableEmployees: async (): Promise<Array<{
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
firstNameAr: string | null
|
||||
lastNameAr: string | null
|
||||
}>> => {
|
||||
const response = await api.get('/tenders/assignable-employees')
|
||||
return response.data.data
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user