diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 738eca8..e3bd1a6 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -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. diff --git a/src/components/TenderDetailModal.jsx b/src/components/TenderDetailModal.jsx index a910775..af46669 100644 --- a/src/components/TenderDetailModal.jsx +++ b/src/components/TenderDetailModal.jsx @@ -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 (
@@ -420,7 +439,19 @@ export const TenderDetailModal = () => { > Takvim Dosyasını İndir (.ics) -

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.

+
+ 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ğı + + 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ğı + +
+

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.

diff --git a/src/services/tenderCalendar.js b/src/services/tenderCalendar.js index ae8abc0..fba47f2 100644 --- a/src/services/tenderCalendar.js +++ b/src/services/tenderCalendar.js @@ -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); diff --git a/tests/calendar.test.mjs b/tests/calendar.test.mjs index 9eba25e..dbb9872 100644 --- a/tests/calendar.test.mjs +++ b/tests/calendar.test.mjs @@ -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'],