add prefilled calendar provider links

This commit is contained in:
2026-09-06 19:57:35 +03:00
parent ecbcc2f646
commit 528aba16ae
4 changed files with 90 additions and 5 deletions
+6
View File
@@ -2320,3 +2320,9 @@ Doğrulama: yerel container probe `pass:true`; kanıt [`qa-container-runtime.jso
Nginx CSP, HSTS ve `X-Frame-Options` başlıklarını da yayınlıyor; Node upstream'inin üç ortak başlığı nginx'te gizlenerek tekrar gönderim önleniyor. Drift probe readiness yanıtında gerçek `{ok:true}` sözleşmesini denetliyor, HTTPS hedeflerinde HSTS'i zorunlu tutuyor ve HTTP yerel container kontrollerinde HSTS'i gözlemlemeye devam ediyor.
Doğrulama: `nginx -t`, Docker üretim build'i ve yerel container probe `pass:true`; CSP, HSTS, clickjacking ve temel güvenlik başlıklarının tamamı görüldü. Production probe'u deployment yapılana kadar başarısız kalmaya devam ediyor.
### 2026-09-06 · Google ve Outlook takvim taslak bağlantıları
İhale detayındaki takvim bölümüne, doğrulanmış İstanbul son teklif tarihini kullanan Google Calendar ve Outlook web taslak bağlantıları eklendi. Bağlantılar başlık, İKN, kurum, yer ve resmî kaynağı yeniden kontrol etme notunu önceden dolduruyor; kaynakta saat yoksa bağlantı üretilmiyor ve hata yalnız kullanıcı tıkladığında gösteriliyor. Otomatik OAuth senkronizasyonu, kayıt güncellemesi veya dış sağlayıcıya veri gönderimi yapılmıyor; kullanıcı taslağı kendi hesabında kaydediyor.
Doğrulama: `npm test` 301/301, `npm run build` başarılı ve `npm run qa:browser` farklı cihaz saat dilimi (`America/New_York`) ile başarılı. Gerçek Google/Outlook hesaplarında OAuth, kayıt ve değişiklik sonrası yeniden senkron kabulü hâlâ açık.
+35 -4
View File
@@ -1,5 +1,5 @@
import { TenderSourceHistory } from './TenderSourceHistory';
import React, { useState, useEffect, useId } from 'react';
import React, { useState, useEffect, useId, useMemo } from 'react';
import {useModalFocus} from '../hooks/useModalFocus';
import { useApp } from '../context/AppContext';
import { BidPackageBuilder } from './BidPackageBuilder';
@@ -12,7 +12,7 @@ import {
Send, Stamp, ArrowLeft, ExternalLink, Printer, PenLine } from 'lucide-react';
import { acknowledgeNoteDraft, editNoteDraft } from '../services/noteDrafts';
import { tenderStateKey } from '../services/tenderState';
import { createTenderICS } from '../services/tenderCalendar';
import { createTenderICS, googleCalendarUrl, outlookCalendarUrl } from '../services/tenderCalendar';
import { validateExchangeAmount, MAX_EXCHANGE_AMOUNT } from '../services/exchangeAmount';
export const TenderDetailModal = () => {
@@ -52,9 +52,20 @@ export const TenderDetailModal = () => {
const fxIsStale = Boolean(fx?.refreshError);
const bidAmountValid = validateExchangeAmount(bidAmount);
// Keep provider links deterministic during render; invalid source dates are
// reported only when the user tries to open a link.
const tender = (activeTenderDetail && tenders.find((t) => t.id === activeTenderDetail.id)) || activeTenderDetail;
const calendarUrls = useMemo(() => {
if (!tender) return { google: null, outlook: null, error: null };
try {
return { google: googleCalendarUrl(tender), outlook: outlookCalendarUrl(tender), error: null };
} catch (e) {
return { google: null, outlook: null, error: e.message };
}
}, [tender]);
if (!activeTenderDetail) return null;
// Havuzdaki güncel kayıt: takip/görev değişince düğme etiketleri de değişsin.
const tender = (activeTenderDetail && tenders.find((t) => t.id === activeTenderDetail.id)) || activeTenderDetail;
const savedNotes = tender.notes || [];
const handleAddNote = async (e) => {
@@ -83,6 +94,14 @@ export const TenderDetailModal = () => {
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
};
const handleCalendarOpen = (provider, event) => {
if (!calendarUrls[provider]) {
event.preventDefault();
setActionError(calendarUrls.error || 'Takvim bağlantısı oluşturulamadı.');
return;
}
setActionError(null);
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto bg-slate-900/70 backdrop-blur-xs flex justify-center p-2 sm:p-4 animate-fade-in">
@@ -420,7 +439,19 @@ export const TenderDetailModal = () => {
>
<Calendar className="w-4 h-4 text-blue-600" /> Takvim Dosyasını İndir (.ics)
</button>
<p className="text-xs text-slate-600 mt-2">Bu dosya takvime otomatik senkron göndermez. Tarih veya saat değişirse yeni dosyayı indirip Google/Outlook kaydını güncelleyin; aynı etkinlik kimliği ve sürümü destekleniyorsa mevcut kayıt güncellenir.</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<a href={calendarUrls.google || '#'} target="_blank" rel="noopener noreferrer"
onClick={(event) => handleCalendarOpen('google', event)}
className="text-center bg-white hover:bg-blue-50 text-blue-700 font-bold py-2 px-3 rounded-lg text-xs border border-blue-200">
Google Takvim taslağı
</a>
<a href={calendarUrls.outlook || '#'} target="_blank" rel="noopener noreferrer"
onClick={(event) => handleCalendarOpen('outlook', event)}
className="text-center bg-white hover:bg-blue-50 text-blue-700 font-bold py-2 px-3 rounded-lg text-xs border border-blue-200">
Outlook taslağı
</a>
</div>
<p className="text-xs text-slate-600 mt-2">Google ve Outlook bağlantıları doğrulanmış tarih/saatle ön-doldurulmuş taslak açar; kaydetme ve değişiklik sonrası yeniden senkronizasyon kullanıcıya aittir. Otomatik OAuth senkronu yapılmaz.</p>
</div>
</div>
+37
View File
@@ -40,6 +40,43 @@ export function createTenderICS(t, now = new Date()) {
'END:VEVENT','END:VCALENDAR',''].map(fold).join('\r\n');
}
const calendarInstant = date => date.toISOString().replace(/\.000Z$/, 'Z');
const calendarText = t => [
`İKN: ${t.ikn || t.id || ''}`,
t.kurum || '',
'Kaynak sürümünü ve ihale tarihini resmi kaynaktan doğrulayın.',
].filter(Boolean).join('\n');
/** Google Calendar'a kaynak tarihiyle ön-doldurulmuş etkinlik taslağı açar. */
export function googleCalendarUrl(t) {
const start = tenderDeadline(t);
const instant = calendarInstant(start).replace(/[-:]/g, '');
const params = new URLSearchParams({
action: 'TEMPLATE',
text: `İhale son teklif: ${t.baslik || t.ikn || 'İhale'}`,
dates: `${instant}/${instant}`,
details: calendarText(t),
location: t.ihaleYeri || t.teslimYeri || '',
});
return `https://calendar.google.com/calendar/render?${params}`;
}
/** Outlook web takviminde kaynak tarihiyle ön-doldurulmuş taslak açar. */
export function outlookCalendarUrl(t) {
const start = tenderDeadline(t);
const instant = calendarInstant(start);
const params = new URLSearchParams({
path: '/calendar/action/compose',
rru: 'addevent',
startdt: instant,
enddt: instant,
subject: `İhale son teklif: ${t.baslik || t.ikn || 'İhale'}`,
body: calendarText(t),
location: t.ihaleYeri || t.teslimYeri || '',
});
return `https://outlook.live.com/calendar/0/deeplink/compose?${params}`;
}
function parseICSDate(value, params = '') {
const raw = String(value || '').trim();
const match = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})Z?)?$/.exec(raw);
+12 -1
View File
@@ -1,11 +1,22 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createTenderICS, parseTenderICS, tenderDeadline, deadlineInfo, calendarMonthDays, istanbulDateKey } from '../src/services/tenderCalendar.js';
import { createTenderICS, parseTenderICS, tenderDeadline, deadlineInfo, calendarMonthDays, istanbulDateKey, googleCalendarUrl, outlookCalendarUrl } from '../src/services/tenderCalendar.js';
const t = {ikn:'2026/1582991',baslik:'Mikroskop',kurum:'Giresun',tarih:'10.09.2026',saat:'',aciklama:'Tarih ve Saati : 10.09.2026 - 10:00'};
test('Giresun date is September 10 at 10:00 Turkey, not hard-coded August', () => {
assert.equal(tenderDeadline(t).toISOString(),'2026-09-10T07:00:00.000Z');
assert.match(createTenderICS(t), /DTSTART:20260910T070000Z/);
});
test('Google and Outlook calendar links preserve the Istanbul deadline', () => {
const google = new URL(googleCalendarUrl({...t, ihaleYeri:'Giresun salonu'}));
assert.equal(google.hostname, 'calendar.google.com');
assert.equal(google.searchParams.get('dates'), '20260910T070000Z/20260910T070000Z');
assert.equal(google.searchParams.get('location'), 'Giresun salonu');
const outlook = new URL(outlookCalendarUrl(t));
assert.equal(outlook.hostname, 'outlook.live.com');
assert.equal(outlook.searchParams.get('startdt'), '2026-09-10T07:00:00Z');
assert.equal(outlook.searchParams.get('enddt'), '2026-09-10T07:00:00Z');
assert.throws(() => googleCalendarUrl({...t,saat:'' ,aciklama:''}), /saati kaynakta bulunamadı/);
});
test('all five tender deadlines and year rollover', () => {
for (const [date,time,expected] of [
['09.09.2026','10:00','2026-09-09T07:00'],['11.09.2026','10:00','2026-09-11T07:00'],